feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242)

* Polish workspace panel behavior and app dialogs

* Replace remaining emoji UI glyphs with Lucide icons

* Redesign composer footer around model and context controls

Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill.

Design credit and inspiration: Theo / T3 Code.
Reference implementation: https://github.com/pingdotgg/t3code/

* Remove obsolete activity bar

Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts.

This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status.

* Move workspace and model switching into composer footer

* Move profile switching into composer footer

* Refactor Hermes control center UI

* Redesign control center settings modal layout

Widen the modal to 860px, simplify the tab list to icon+label rows,
stretch the tab column's divider to full height, lock the panel to a
fixed height so switching tabs no longer resizes the outer shell, and
always open on the Conversation tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Put session item actions in a dropdown

* Use Hermes mark in sidebar control button

* Reset control center section on close

* Drop session-item left border indicator

Remove the left-border accent used for active, CLI, and project rows —
each state already has a dedicated cue (gold fill, cli badge, project
dot), so the border was redundant. Fully round the row, add 2px
bottom spacing between rows, and strip the matching JS/CSS overrides.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Increase session search input vertical padding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Normalise odd pixel values across UI

Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid
across composer chips, sidebar panels, cron list, settings, approval
buttons, dropdowns, and inline message edit — eliminating the 7/9/11px
drift that was making sibling elements feel subtly misaligned.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite)

The mobile layout regression suite (test_mobile_layout.py) requires:
- #btnMobileFiles onclick=toggleMobileFiles() in topbar chips
- .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints

Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports.

* Improve composer footer mobile responsiveness and UX

- Collapse composer chips to icon-only at <=400px viewports
- Add model chip icon (CPU) so it remains tappable when labels are hidden
- Show send button always (disabled state when empty, hidden during streaming)
- Show context usage indicator on session load, not just after streaming
- Add cancel status fallback timeout to prevent stale "Cancelling..." text
- Update tests to match new send button and busy state behavior

* Fix duplicate files button and broken workspace close on mobile

Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle
in the mobile topbar. Fix workspace panel close button calling undefined
closeMobileFiles() — now calls closeWorkspacePanel().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix model chip icon vertical alignment in composer footer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix workspace toggle button hidden on desktop by conflicting CSS class

Remove mobile-files-btn class from #btnWorkspacePanelToggle — its
display:none!important rule was overriding workspace-toggle-btn visibility
on non-mobile viewports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix session actions dots button inaccessible on mobile sidebar

Always show the session actions trigger on mobile (no hover state on
touch devices) and restore right padding so text truncates with
ellipsis before the dots icon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix composer footer manage links not opening sidebar panel

The "Manage profiles" and "Manage workspaces" links in the composer
footer dropdowns called switchPanel() which only changes the active
panel content but doesn't open the sidebar. Replaced with
mobileSwitchPanel() which also opens the sidebar so the panel is
actually visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Widen icon-only composer chips breakpoint from 400px to 768px

Move the icon-only chip styling up into the existing max-width:768px
media query so chips collapse to icon-only on tablets too, preventing
composer footer overflow on mid-size screens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix composer-left vertical scrollbar by setting overflow-y:hidden

When overflow-x is set to auto, the CSS spec implicitly changes
overflow-y from visible to auto, allowing a vertical scrollbar to
appear from slight chip padding/border overflow. Explicitly set
overflow-y:hidden to prevent this.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve rebase conflicts and fix control center test assertions

- Resolved 4 conflicts during rebase onto master (workspace.js,
  boot.js, index.html, test_sprint34.py)
- Fixed test_sprint34.py: _controlSection -> _settingsSection,
  cc-tab -> settings-tabs (matching actual implementation)
- Fixed quoting syntax error in test assertion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update version badge in System tab to v0.49.4

* docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge

---------

Co-authored-by: Aron Prins <pwf.aron@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
nesquena-hermes
2026-04-12 11:55:40 -07:00
committed by GitHub
parent ed2d55f020
commit ede1a5fc50
23 changed files with 1333 additions and 610 deletions

View File

@@ -13,14 +13,21 @@
The Hermes Web UI is a lightweight web application that gives you a browser-based The Hermes Web UI is a lightweight web application that gives you a browser-based
interface to the Hermes agent that is functionally equivalent to the CLI. It is modeled on interface to the Hermes agent that is functionally equivalent to the CLI. It is modeled on
the Claude-style interface: a three-panel layout with a sidebar for session management, the Claude-style interface: a sidebar for session management, a central chat area,
a central chat area, and a right panel for workspace file browsing. and a demand-driven right panel used for workspace browsing and preview surfaces.
The right panel is closed by default on desktop and opens only when it is actively
being used for browsing or previewing content.
The design philosophy is deliberately minimal. There is no build step, no bundler, no The design philosophy is deliberately minimal. There is no build step, no bundler, no
frontend framework. The Python server is split into a routing shell (server.py) and frontend framework. The Python server is split into a routing shell (server.py) and
business logic modules (api/). The frontend is seven vanilla JS modules loaded from static/. business logic modules (api/). The frontend is seven vanilla JS modules loaded from static/.
This makes the code easy to modify from a terminal or by an agent. This makes the code easy to modify from a terminal or by an agent.
Hermes-level chrome is intentionally consolidated: the sidebar has no dedicated brand header.
Instead, the footer exposes a single "Hermes WebUI" launch button that opens one tabbed
control-center modal for global preferences, conversation import/export, and clear-conversation
actions. The topbar remains focused on conversation context and the workspace/files toggle.
--- ---
## 2. File Inventory ## 2. File Inventory
@@ -51,7 +58,7 @@ This makes the code easy to modify from a terminal or by an agent.
style.css All CSS incl. mobile responsive (~670 lines) style.css All CSS incl. mobile responsive (~670 lines)
ui.js DOM helpers, renderMd, tool cards, model dropdown, file tree (~977 lines) ui.js DOM helpers, renderMd, tool cards, model dropdown, file tree (~977 lines)
workspace.js File preview, file ops, loadDir, clearPreview (~185 lines) workspace.js File preview, file ops, loadDir, clearPreview (~185 lines)
sessions.js Session CRUD, list rendering, search, SVG icons, overlay actions (~533 lines) sessions.js Session CRUD, list rendering, search, SVG icons, dropdown actions (~533 lines)
messages.js send(), SSE event handlers, approval, transcript (~297 lines) messages.js send(), SSE event handlers, approval, transcript (~297 lines)
panels.js Cron, skills, memory, workspace, profiles, todo, settings (~974 lines) panels.js Cron, skills, memory, workspace, profiles, todo, settings (~974 lines)
commands.js Slash command registry, parser, autocomplete dropdown (~156 lines) commands.js Slash command registry, parser, autocomplete dropdown (~156 lines)
@@ -351,7 +358,7 @@ highlighting) and Mermaid.js (diagrams) from CDN, both loaded async/deferred wit
Six JS modules loaded in order at end of <body>: Six JS modules loaded in order at end of <body>:
1. ui.js (~846 lines) DOM helpers, renderMd, tool card rendering, global state 1. ui.js (~846 lines) DOM helpers, renderMd, tool card rendering, global state
2. workspace.js (~169 lines) File tree, preview, file operations 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 3. sessions.js (~532 lines) Session CRUD, list rendering, search, SVG icons, dropdown actions, project picker
4. messages.js (~293 lines) send(), SSE event handlers, approval, transcript 4. messages.js (~293 lines) send(), SSE event handlers, approval, transcript
5. panels.js (~771 lines) Cron, skills, memory, workspace, todo, switchPanel 5. panels.js (~771 lines) Cron, skills, memory, workspace, todo, switchPanel
6. boot.js (~175 lines) Event wiring + boot IIFE 6. boot.js (~175 lines) Event wiring + boot IIFE
@@ -362,10 +369,19 @@ inherit `currentColor` for consistent theming.
Three-panel layout (in static/index.html): Three-panel layout (in static/index.html):
<aside class="sidebar"> Left panel: session list, nav tabs, model selector <aside class="sidebar"> Left panel: session list, nav tabs, sidebar-footer Hermes WebUI trigger
<main class="main"> Center: topbar, messages area, approval card, composer <main class="main"> Center: topbar, messages area, approval card, composer
<aside class="rightpanel"> Right panel: workspace file tree and file preview <aside class="rightpanel"> Right panel: workspace file tree and file preview
Composer footer layout (current):
left cluster attach button, mic button, per-conversation model selector
right cluster compact circular context-usage badge, send button
The model selector is still the authoritative control for new-session creation
and session updates; it was moved out of the sidebar so model choice feels scoped
to the active conversation rather than a global app setting.
### 5.2 Global State ### 5.2 Global State
const S = { const S = {
@@ -410,11 +426,19 @@ Approval:
stopApprovalPolling clearInterval stopApprovalPolling clearInterval
UI helpers: UI helpers:
setStatus(t) Updates #statusText in composer footer setStatus(t) Fallback helper: shows a toast for non-chat status/error messages
setComposerStatus(t) Updates the inline composer status label for turn-scoped states
setBusy(v) Sets S.busy, disables/enables Send button, clears status on false setBusy(v) Sets S.busy, disables/enables Send button, clears status on false
showToast(msg, ms) Bottom-center fade toast (default 2800ms) showToast(msg, ms) Bottom-center fade toast (default 2800ms)
showConfirmDialog(o) Shared in-app confirmation modal, resolves true/false
showPromptDialog(o) Shared in-app input modal, resolves string/null
autoResize() Auto-resize #msg textarea up to 200px autoResize() Auto-resize #msg textarea up to 200px
Dialog policy:
Native browser confirm()/prompt() are not used in the Web UI.
Destructive actions use showConfirmDialog(...), then a toast on success.
Lightweight naming flows (new file/folder/project) use showPromptDialog(...).
Files: Files:
loadDir(path) GET /api/list, rebuild #fileTree loadDir(path) GET /api/list, rebuild #fileTree
openFile(path) GET /api/file, show in #previewArea openFile(path) GET /api/file, show in #previewArea
@@ -467,7 +491,7 @@ Known gaps:
- Nested lists: single regex pass, multi-level indentation not handled - Nested lists: single regex pass, multi-level indentation not handled
- Mixed bold+link in same line: may produce garbled output - Mixed bold+link in same line: may produce garbled output
### 5.5 Model Chip Label (Fixed in Sprint 1) ### 5.5 Model Label Resolution (Fixed in Sprint 1, reused by composer selector)
B3 was resolved in Sprint 1. Current code uses a MODEL_LABELS dict: B3 was resolved in Sprint 1. Current code uses a MODEL_LABELS dict:
@@ -478,10 +502,10 @@ B3 was resolved in Sprint 1. Current code uses a MODEL_LABELS dict:
'anthropic/claude-haiku-3-5': 'Haiku 3.5', 'google/gemini-2.5-pro': 'Gemini 2.5 Pro', 'anthropic/claude-haiku-3-5': 'Haiku 3.5', 'google/gemini-2.5-pro': 'Gemini 2.5 Pro',
'deepseek/deepseek-chat-v3-0324': 'DeepSeek V3', 'meta-llama/llama-4-scout': 'Llama 4 Scout', 'deepseek/deepseek-chat-v3-0324': 'DeepSeek V3', 'meta-llama/llama-4-scout': 'Llama 4 Scout',
}; };
$('modelChip').textContent = MODEL_LABELS[m] || (m.split('/').pop() || 'Unknown'); getModelLabel(m) => MODEL_LABELS[m] || (m.split('/').pop() || 'Unknown');
Fallback: any unlisted model shows its short ID (after the last /) rather than a wrong label. Fallback: any unlisted model shows its short ID (after the last /) rather than a wrong label.
To add a new model: add an entry to MODEL_LABELS and add an <option> to the <select>. To add a new model: add an entry to MODEL_LABELS and add an <option> to the composer footer <select>.
### 5.6 Session Delete Rules (from skill) ### 5.6 Session Delete Rules (from skill)
@@ -1099,7 +1123,7 @@ The model chip label bug is now fixed. The MODEL_LABELS object in syncTopbar():
'deepseek/deepseek-chat-v3-0324': 'DeepSeek V3', 'deepseek/deepseek-chat-v3-0324': 'DeepSeek V3',
'meta-llama/llama-4-scout': 'Llama 4 Scout', 'meta-llama/llama-4-scout': 'Llama 4 Scout',
}; };
$('modelChip').textContent = MODEL_LABELS[m] || (m.split('/').pop() || 'Unknown'); getModelLabel(m) => MODEL_LABELS[m] || (m.split('/').pop() || 'Unknown');
Fallback: splits on '/' and uses the last segment, so any unlisted model shows its Fallback: splits on '/' and uses the last segment, so any unlisted model shows its
short identifier rather than a wrong hardcoded label. short identifier rather than a wrong hardcoded label.

View File

@@ -6,6 +6,21 @@
--- ---
## [v0.50.0] Composer-centric UI refresh + Hermes Control Center (PR #242)
Major UI overhaul by [@aronprins](https://github.com/aronprins), rebased and reviewed on `pr-242-review`.
- **Composer as control hub** — model selector, profile chip, and workspace chip now live in the composer footer as pill buttons with dropdowns. The context window usage ring (token count, cost, fill) replaces the old linear pill.
- **Hermes Control Center** — a single sidebar launcher button (bottom of sidebar) replaces the gear icon settings modal. Tabbed 860px modal: Conversation tab (transcript/JSON export, import, clear), Preferences tab (all settings), System tab (version, password). Always resets to Conversation on close.
- **Activity bar removed** — turn-scoped status (thinking, cancelling) renders inline in the composer footer via `setComposerStatus`.
- **Session `⋯` dropdown** — per-row pin/archive/duplicate/move/delete actions move from inline buttons into a shared dropdown menu; click-outside/scroll/Escape handling.
- **Workspace panel state machine** — `_workspacePanelMode` (`closed`/`browse`/`preview`) in boot.js with proper transitions and discard-unsaved guard.
- **Icon additions** — save, chevron-right, arrow-right, pause, paperclip, copy, rotate-ccw, user added to icons.js.
- **i18n additions** — 6 new keys across en/de/zh/zh-Hant for control center sections.
- **OLED theme** — 7th built-in theme (true black background for OLED displays).
- **Mobile fixes** — icon-only composer chips below 640px, `overflow-y: hidden` on `.composer-left` to prevent scrollbar, profile dropdown `max-width: min(260px, calc(100vw - 32px))`.
- 742 tests total; all existing tests pass; version badge in System tab updated to v0.50.0.
## [v0.49.4] Cancel stream cleanup guaranteed (PR #309, fixes #299) ## [v0.49.4] Cancel stream cleanup guaranteed (PR #309, fixes #299)
- **Reliable cancel cleanup** (closes #299): `cancelStream()` no longer depends on the SSE `cancel` event to clear busy state and status text. Previously, if the SSE connection was already closed when cancel fired, "Cancelling..." would linger indefinitely. Now `cancelStream()` clears `S.activeStreamId`, calls `setBusy(false)`, `setStatus('')`, and hides the cancel button directly after the cancel API request — regardless of SSE connection state. The SSE cancel handler still runs when the connection is alive (all operations are idempotent). - **Reliable cancel cleanup** (closes #299): `cancelStream()` no longer depends on the SSE `cancel` event to clear busy state and status text. Previously, if the SSE connection was already closed when cancel fired, "Cancelling..." would linger indefinitely. Now `cancelStream()` clears `S.activeStreamId`, calls `setBusy(false)`, `setStatus('')`, and hides the cancel button directly after the cancel API request — regardless of SSE connection state. The SSE cancel handler still runs when the connection is alive (all operations are idempotent).
@@ -279,7 +294,7 @@
notification when the tab is in the background. notification when the tab is in the background.
- **Thinking / reasoning block display** (PR #181, #182): Inline `<think>…</think>` - **Thinking / reasoning block display** (PR #181, #182): Inline `<think>…</think>`
and Gemma 4 `<|channel>thought…<channel|>` tags are parsed out of assistant and Gemma 4 `<|channel>thought…<channel|>` tags are parsed out of assistant
messages and rendered as a collapsible 💡 "Thinking" card above the reply. messages and rendered as a collapsible lightbulb "Thinking" card above the reply.
During streaming, the bubble shows "Thinking…" until the tag closes. Hardened During streaming, the bubble shows "Thinking…" until the tag closes. Hardened
against partial-tag edge cases and empty thinking blocks. against partial-tag edge cases and empty thinking blocks.
@@ -687,7 +702,7 @@
command. Persists server-side across refreshes. command. Persists server-side across refreshes.
- **Subagent delegation cards.** `subagent_progress` events now render with - **Subagent delegation cards.** `subagent_progress` events now render with
a 🔀 icon and a blue indented left border to visually distinguish child a shuffle icon and a blue indented left border to visually distinguish child
tool activity from parent tool calls. `delegate_task` cards display as tool activity from parent tool calls. `delegate_task` cards display as
"Delegate task" with cleaner formatting. "Delegate task" with cleaner formatting.
@@ -1483,9 +1498,9 @@ The sprint that closed the last gaps for heavy agentic use.
restored from session history on reload. Shows tool name, preview, args, result snippet. restored from session history on reload. Shows tool name, preview, args, result snippet.
- **Attachment metadata persists on reload.** File badges on user messages survive page - **Attachment metadata persists on reload.** File badges on user messages survive page
refresh. Server stores filenames on the user message in session JSON. refresh. Server stores filenames on the user message in session JSON.
- **Todo list panel.** New checkmark tab in the sidebar. Shows current task list parsed - **Todo list panel.** New task-list tab in the sidebar. Shows current task list parsed
from the most recent todo tool result in message history. Status icons: pending (○), from the most recent todo tool result in message history. Status icons use Lucide
in-progress (◉), completed (✓), cancelled (✗). Auto-refreshes when panel is active. square, loader, check, and x states. Auto-refreshes when panel is active.
- **Model preference persists.** Last-used model saved to localStorage. Restored on page - **Model preference persists.** Last-used model saved to localStorage. Restored on page
load. New sessions inherit it automatically. load. New sessions inherit it automatically.

View File

@@ -7,8 +7,11 @@ Full parity with the CLI experience - everything you can do from a terminal,
you can do from this UI. No build step, no framework, no bundler. Just Python you can do from this UI. No build step, no framework, no bundler. Just Python
and vanilla JS. and vanilla JS.
Layout: three-panel Claude-style. Left sidebar for sessions and tools, Layout: three-panel. Left sidebar for sessions and navigation, center for chat,
center for chat, right for workspace file browsing. right for workspace file browsing. Model, profile, and workspace controls live in
the **composer footer** — always visible while composing. A circular context ring
shows token usage at a glance. All settings and session tools are in the
**Hermes Control Center** (launcher at the sidebar bottom).
<img alt="Hermes Web UI — three-panel layout" width="1417" height="867" alt="image" src="https://github.com/user-attachments/assets/51adff98-53ee-4800-8508-78b6c34dd3dc" /> <img alt="Hermes Web UI — three-panel layout" width="1417" height="867" alt="image" src="https://github.com/user-attachments/assets/51adff98-53ee-4800-8508-78b6c34dd3dc" />
@@ -349,7 +352,7 @@ across 23 test files.
- Send a message while one is processing -- it queues automatically - Send a message while one is processing -- it queues automatically
- Edit any past user message inline and regenerate from that point - Edit any past user message inline and regenerate from that point
- Retry the last assistant response with one click - Retry the last assistant response with one click
- Cancel a running task from the activity bar - Cancel a running task directly from the composer footer (Stop button next to Send)
- Tool call cards inline -- each shows the tool name, args, and result snippet; expand/collapse all toggle for multi-tool turns - Tool call cards inline -- each shows the tool name, args, and result snippet; expand/collapse all toggle for multi-tool turns
- Subagent delegation cards -- child agent activity shown with distinct icon and indented border - Subagent delegation cards -- child agent activity shown with distinct icon and indented border
- Mermaid diagram rendering inline (flowcharts, sequence diagrams, gantt charts) - Mermaid diagram rendering inline (flowcharts, sequence diagrams, gantt charts)
@@ -366,6 +369,7 @@ across 23 test files.
### Sessions ### Sessions
- Create, rename, duplicate, delete, search by title and message content - Create, rename, duplicate, delete, search by title and message content
- Session actions via `⋯` dropdown per session — pin, move to project, archive, duplicate, delete
- Pin/star sessions to the top of the sidebar (gold indicator) - Pin/star sessions to the top of the sidebar (gold indicator)
- Archive sessions (hide without deleting, toggle to show) - Archive sessions (hide without deleting, toggle to show)
- Session projects -- named groups with colors for organizing sessions - Session projects -- named groups with colors for organizing sessions
@@ -397,7 +401,7 @@ across 23 test files.
- Hidden when browser doesn't support Web Speech API (Chrome, Edge, Safari) - Hidden when browser doesn't support Web Speech API (Chrome, Edge, Safari)
### Profiles ### Profiles
- Profile picker in the topbar -- purple chip with dropdown showing all profiles - Profile chip in the **composer footer** -- dropdown showing all profiles with gateway status and model info
- Gateway status dots (green = running), model info, skill count per profile - Gateway status dots (green = running), model info, skill count per profile
- Profiles management panel -- create, switch, and delete profiles from the sidebar - Profiles management panel -- create, switch, and delete profiles from the sidebar
- Clone config from active profile on create - Clone config from active profile on create
@@ -415,16 +419,17 @@ across 23 test files.
- CDN resources pinned with SRI integrity hashes - CDN resources pinned with SRI integrity hashes
### Themes ### Themes
- 6 built-in themes: Dark (default), Light, Slate, Solarized Dark, Monokai, Nord - 7 built-in themes: Dark (default), Light, Slate, Solarized Dark, Monokai, Nord, OLED
- Switch via Settings panel dropdown (instant live preview) or `/theme` command - Switch via Settings panel dropdown (instant live preview) or `/theme` command
- Persists across reloads (server-side in settings.json + localStorage for flicker-free loading) - Persists across reloads (server-side in settings.json + localStorage for flicker-free loading)
- Custom themes: define a `:root[data-theme="name"]` CSS block and it works — see [THEMES.md](THEMES.md) - Custom themes: define a `:root[data-theme="name"]` CSS block and it works — see [THEMES.md](THEMES.md)
### Settings and configuration ### Settings and configuration
- Settings panel (gear icon) -- default model, default workspace, send key, theme - **Hermes Control Center** (sidebar launcher button) -- Conversation tab (export/import/clear), Preferences tab (model, send key, theme, language, all toggles), System tab (version, password)
- Send key: Enter (default) or Ctrl/Cmd+Enter - Send key: Enter (default) or Ctrl/Cmd+Enter
- Show/hide CLI sessions toggle (enabled by default) - Show/hide CLI sessions toggle (enabled by default)
- Token usage display toggle (off by default, also via `/usage` command) - Token usage display toggle (off by default, also via `/usage` command)
- Control Center always opens on the Conversation tab; resets on close
- Unsaved changes guard -- discard/save prompt when closing with unpersisted changes - Unsaved changes guard -- discard/save prompt when closing with unpersisted changes
- Cron completion alerts -- toast notifications and unread badge on Tasks tab - Cron completion alerts -- toast notifications and unread badge on Tasks tab
- Background agent error alerts -- banner when a non-active session encounters an error - Background agent error alerts -- banner when a non-active session encounters an error
@@ -469,19 +474,19 @@ api/
upload.py Multipart parser, file upload handler (~78 lines) upload.py Multipart parser, file upload handler (~78 lines)
workspace.py File ops, workspace helpers, git detection (~288 lines) workspace.py File ops, workspace helpers, git detection (~288 lines)
static/ static/
index.html HTML template (~388 lines) index.html HTML template (~600 lines)
style.css All CSS incl. mobile responsive (~726 lines) style.css All CSS incl. mobile responsive, themes (~855 lines)
ui.js DOM helpers, renderMd, tool cards, context indicator (~1063 lines) ui.js DOM helpers, renderMd, tool cards, context ring (~1090 lines)
workspace.js File preview, file ops, git badge (~247 lines) workspace.js File preview, file ops, git badge (~247 lines)
sessions.js Session CRUD, collapsible groups, search (~589 lines) sessions.js Session CRUD, ⋯ dropdown, collapsible groups, search (~600 lines)
messages.js send(), SSE handlers, rAF throttle (~352 lines) messages.js send(), SSE handlers, rAF throttle (~352 lines)
panels.js Cron, skills, memory, profiles, settings (~1146 lines) panels.js Cron, skills, memory, profiles, control center (~1200 lines)
commands.js Slash command autocomplete (~170 lines) commands.js Slash command autocomplete (~170 lines)
boot.js Mobile nav, voice input, boot IIFE (~338 lines) boot.js Mobile nav, workspace state machine, composer chips, boot IIFE (~420 lines)
tests/ tests/
conftest.py Isolated test server (port 8788) conftest.py Isolated test server (port 8788)
test_sprint{1-23}.py 22 test files, 426 test functions test_sprint{1-36}.py 36 test files, 742 test functions
test_regressions.py Permanent regression gate (23 tests) test_regressions.py Permanent regression gate
Dockerfile python:3.12-slim container image Dockerfile python:3.12-slim container image
docker-compose.yml Compose with named volume and optional auth docker-compose.yml Compose with named volume and optional auth
.github/workflows/ CI: multi-arch Docker build + GitHub Release on tag .github/workflows/ CI: multi-arch Docker build + GitHub Release on tag

View File

@@ -33,7 +33,7 @@
| Sprint 13 | Alerts + polish | Cron completion alerts (polling + badge), background error banner, session duplicate, browser tab title | 221 | | 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 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 15 | Session projects + code copy | Session projects/folders, code block copy button, tool card expand/collapse toggle | 237 |
| Sprint 16 | Session sidebar visual polish | SVG action icons, overlay hover actions, pin indicator, project border, safe HTML rendering | 289 | | Sprint 16 | Session sidebar visual polish | SVG action icons, session action dropdown, pin indicator, project border, safe HTML rendering | 289 |
| Sprint 17 | Workspace polish + slash commands + settings | Breadcrumb navigation, slash command autocomplete, send key setting (#26) | 318 | | Sprint 17 | Workspace polish + slash commands + settings | Breadcrumb navigation, slash command autocomplete, send key setting (#26) | 318 |
| Sprint 18 | Thinking display + workspace tree | File preview auto-close, thinking/reasoning cards, expandable directory tree (#22) | 318 | | Sprint 18 | Thinking display + workspace tree | File preview auto-close, thinking/reasoning cards, expandable directory tree (#22) | 318 |
| Sprint 19 | Auth + security hardening | Password auth (off by default), login page, security headers, 20MB body limit (#23) | 328 | | Sprint 19 | Auth + security hardening | Password auth (off by default), login page, security headers, 20MB body limit (#23) | 328 |
@@ -85,11 +85,12 @@
### Chat and Agent ### Chat and Agent
- [x] Send messages, get SSE-streaming responses - [x] Send messages, get SSE-streaming responses
- [x] Switch models per session (10 models, grouped by provider) - [x] Switch models per session (10 models, grouped by provider)
- [x] Composer-scoped model picker in footer (moved from sidebar to align with per-conversation model selection)
- [x] Multi-provider API support: use any Hermes agent API provider (OpenAI, Anthropic, Google, etc.) directly, not just OpenRouter (Sprint 11) - [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] 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] Upload files to workspace (drag-drop, click, clipboard paste)
- [x] File tray with remove button - [x] File tray with remove button
- [x] Tool progress shown in activity bar above composer - [x] Tool progress shown inline in the conversation via live tool cards
- [x] Approval card for dangerous commands (Allow once/session/always, Deny) - [x] Approval card for dangerous commands (Allow once/session/always, Deny)
- [x] Approval polling + SSE-pushed approval events - [x] Approval polling + SSE-pushed approval events
- [x] INFLIGHT guard: switch sessions mid-request without losing response - [x] INFLIGHT guard: switch sessions mid-request without losing response
@@ -101,23 +102,25 @@
- [x] Token/cost estimate per message (Sprint 23) - [x] Token/cost estimate per message (Sprint 23)
### Tool Visibility ### Tool Visibility
- [x] Tool progress in activity bar (moved out of composer footer) - [x] Tool progress in live tool cards (kept out of the composer/footer chrome)
- [x] Approval card with all 4 choices - [x] Approval card with all 4 choices
- [x] Tool call cards inline (collapsed, show name/args/result) - [x] Tool call cards inline (collapsed, show name/args/result)
### Workspace / Files ### Workspace / Files
- [x] Workspace panel defaults closed and opens only for active browsing or preview
- [x] Browse workspace directory tree with type icons - [x] Browse workspace directory tree with type icons
- [x] Preview text/code files (read-only) - [x] Preview text/code files (read-only)
- [x] Preview markdown files (rendered, tables supported) - [x] Preview markdown files (rendered, tables supported)
- [x] Preview image files (PNG, JPG, GIF, SVG, WEBP inline) - [x] Preview image files (PNG, JPG, GIF, SVG, WEBP inline)
- [x] Edit files inline (Edit button, Enter to save, Escape to cancel) - [x] Edit files inline (Edit button, Enter to save, Escape to cancel)
- [x] Create new file (+ button in panel header) - [x] Create new file (+ button in panel header)
- [x] Delete file (hover trash, confirm dialog) - [x] Delete file (hover trash, confirmation modal)
- [x] File name truncation with tooltip for long names - [x] File name truncation with tooltip for long names
- [x] Right panel resizable (drag inner edge) - [x] Right panel resizable (drag inner edge)
- [x] Syntax highlighted code preview (Prism.js) - [x] Syntax highlighted code preview (Prism.js)
- [x] Rename file (Sprint 14) - [x] Rename file (Sprint 14)
- [x] Create folder (Sprint 14) - [x] Create folder (Sprint 14)
- [x] Shared app modal for confirm/input flows (Sprint 33)
### Sessions ### Sessions
- [x] Create session (+ button or Cmd/Ctrl+K) - [x] Create session (+ button or Cmd/Ctrl+K)
@@ -218,14 +221,14 @@
- [x] Streaming performance -- rAF-throttled token rendering (Sprint 24, PR #81) - [x] Streaming performance -- rAF-throttled token rendering (Sprint 24, PR #81)
- [x] Workspace git detection -- branch name and dirty status badge (Sprint 24, PR #82) - [x] Workspace git detection -- branch name and dirty status badge (Sprint 24, PR #82)
- [x] Collapsible date groups -- click group headers to collapse (Sprint 24, PR #80) - [x] Collapsible date groups -- click group headers to collapse (Sprint 24, PR #80)
- [x] Context usage indicator -- token count and cost in composer footer (Sprint 24, PR #83) - [x] Context usage indicator -- compact circular badge in composer footer (Sprint 24, PR #83; refreshed April 10, 2026)
- [ ] LLM-generated session titles -- auto-title via small model instead of first-message substring (PR #75) - [ ] LLM-generated session titles -- auto-title via small model instead of first-message substring (PR #75)
- [ ] Workspace git detection -- show branch name, dirty status in workspace header (PR #75) - [ ] Workspace git detection -- show branch name, dirty status in workspace header (PR #75)
- [ ] Clarify dialog -- agent can ask clarifying questions that block until user responds (PR #75) - [ ] Clarify dialog -- agent can ask clarifying questions that block until user responds (PR #75)
- [ ] Gateway approval polling -- support blocking approvals from messaging gateway (PR #75) - [ ] Gateway approval polling -- support blocking approvals from messaging gateway (PR #75)
- [ ] Unified session storage -- SessionDB shared between webui and CLI (PR #75) - [ ] Unified session storage -- SessionDB shared between webui and CLI (PR #75)
- [ ] TTS playback of responses (deferred) - [ ] TTS playback of responses (deferred)
- [x] Background task cancel (activity bar Cancel button) - [x] Background task cancel (composer footer stop button)
- [ ] Code execution cell (deferred) - [ ] Code execution cell (deferred)
- [ ] Desktop application (Sprint 25, PLANNED) - [ ] Desktop application (Sprint 25, PLANNED)
- [x] Pluggable UI themes -- Dark, Light, Slate, Solarized, Monokai, Nord (Sprint 26, v0.34) - [x] Pluggable UI themes -- Dark, Light, Slate, Solarized, Monokai, Nord (Sprint 26, v0.34)

View File

@@ -256,7 +256,7 @@ inconsistently across platforms. These were the most common visual complaints.
button now only appears in the hover overlay like all other actions. button now only appears in the hover overlay like all other actions.
### Track B: Features ### Track B: Features
- **SVG action icons.** Replaced all emoji HTML entities (★, 📂, 📦, ⊕, 🗑) - **SVG action icons.** Replaced old symbol and emoji HTML entities
with monochrome SVG line icons that inherit `currentColor`. Consistent with monochrome SVG line icons that inherit `currentColor`. Consistent
rendering across macOS, Linux, and Windows. Icons: pin (star), folder, rendering across macOS, Linux, and Windows. Icons: pin (star), folder,
archive (box), duplicate (overlapping squares), trash (bin with lines). archive (box), duplicate (overlapping squares), trash (bin with lines).
@@ -762,7 +762,7 @@ Both architectures in one .app. No separate downloads needed.
- JS bridge fires when approval card appears/disappears - JS bridge fires when approval card appears/disappears
**Menu bar mode (optional, v2):** **Menu bar mode (optional, v2):**
- A small status bar item (⚗️ icon in menu bar) that opens a compact popover - A small status bar item (beaker icon in menu bar) that opens a compact popover
- Popover shows current session status, last message, quick-compose field - Popover shows current session status, last message, quick-compose field
- Useful for running Hermes in the background without a full window - Useful for running Hermes in the background without a full window

View File

@@ -32,9 +32,11 @@ SETUP: Clear localStorage (DevTools > Application > Local Storage > delete herme
STEPS: STEPS:
1. Navigate to http://localhost:8787 1. Navigate to http://localhost:8787
EXPECT: EXPECT:
- Dark background, Hermes logo in sidebar header - Dark background
- Sidebar begins directly with the icon tab row; there is no dedicated branding header
- Center area shows "What can I help with?" heading with suggestion buttons - Center area shows "What can I help with?" heading with suggestion buttons
- Session list in sidebar is empty or shows existing sessions - Session list in sidebar is empty or shows existing sessions
- Sidebar footer shows a single "Hermes WebUI" control-center button
- No session is highlighted active - No session is highlighted active
- Send button is present but there is no input focus by default - Send button is present but there is no input focus by default
FAIL: Page shows error, blank white screen, or auto-creates a new session without user action. FAIL: Page shows error, blank white screen, or auto-creates a new session without user action.
@@ -73,11 +75,11 @@ STEPS:
EXPECT: EXPECT:
- User message appears immediately in chat - User message appears immediately in chat
- Thinking dots (three animated dots) appear below - Thinking dots (three animated dots) appear below
- Status bar shows "Hermes is thinking..."
- Send button becomes disabled (grayed out) - Send button becomes disabled (grayed out)
- A red stop button appears in the composer footer while the turn is running
- Within 10-30 seconds, Hermes responds with a three-word greeting - Within 10-30 seconds, Hermes responds with a three-word greeting
- Thinking dots disappear - Thinking dots disappear
- Send button re-enables - Send button re-enables and the stop button disappears
- Session title in sidebar updates to reflect the first message - Session title in sidebar updates to reflect the first message
FAIL: Message never appears, thinking dots never go away, Send button stays disabled forever. FAIL: Message never appears, thinking dots never go away, Send button stays disabled forever.
@@ -144,7 +146,7 @@ FAIL: New session created, error thrown, or UI breaks.
### T3.1: Model Dropdown Shows All Options ### T3.1: Model Dropdown Shows All Options
SETUP: Any active session. SETUP: Any active session.
STEPS: STEPS:
1. Look at the sidebar bottom: "Model" label and a dropdown 1. Look at the composer footer: to the right of the attach/mic controls there is a model dropdown
2. Click the dropdown to expand it 2. Click the dropdown to expand it
EXPECT: EXPECT:
- Provider groups visible: OpenAI, Anthropic, Other - Provider groups visible: OpenAI, Anthropic, Other
@@ -153,18 +155,30 @@ EXPECT:
- Other group: Gemini 2.5 Pro, DeepSeek V3, Llama 4 Scout - Other group: Gemini 2.5 Pro, DeepSeek V3, Llama 4 Scout
FAIL: Only 2 options visible, no groups, or missing models. FAIL: Only 2 options visible, no groups, or missing models.
### T3.2: Model Chip Reflects Selection ### T3.2: Model Dropdown Reflects Active Conversation
SETUP: Active session. SETUP: Active session.
STEPS: STEPS:
1. Change model dropdown to "Claude Sonnet 4.6" 1. Change model dropdown to "Claude Sonnet 4.6"
EXPECT: EXPECT:
- The blue chip in the topbar right updates to "Sonnet 4.6" immediately - The composer footer dropdown stays on "Claude Sonnet 4.6"
- NOT "GPT-5.4 Mini" (this was Bug B3, now fixed) - Sending the next message uses that session model rather than an older one from another conversation
STEPS (continued): STEPS (continued):
2. Change model to "Gemini 2.5 Pro" 2. Change model to "Gemini 2.5 Pro"
EXPECT: EXPECT:
- Chip updates to "Gemini 2.5 Pro" (not "GPT-5.4 Mini") - The dropdown updates to "Gemini 2.5 Pro"
FAIL: Chip shows wrong model name for any non-Sonnet selection. - Switching away and back to the conversation restores the same model in the footer selector
FAIL: Dropdown shows the wrong active model after a session switch, or sending uses a stale model.
### T3.3: Context Badge Shares Footer Space Cleanly
SETUP: Active session with at least one completed response.
STEPS:
1. Look at the right side of the composer footer
EXPECT:
- A compact circular context badge appears next to the send button when usage data is available
- The number in the center shows the used percentage
- Hovering or focusing the badge shows a tooltip with percent used, token count, auto-compress threshold, and estimated cost when available
- The model dropdown remains usable without overlapping the send button or pushing controls out of view
FAIL: Linear meter still shown, tooltip missing/incomplete, controls overlap, or footer wraps in a broken way.
--- ---
@@ -228,8 +242,18 @@ FAIL: File not removed, error.
## Section 5: Workspace File Browser ## Section 5: Workspace File Browser
### T5.1: File Tree Loads on Session Start ### T5.0: Panel Is Closed By Default
SETUP: Active session with workspace set. SETUP: Active session with workspace set.
EXPECT:
- Right workspace panel is hidden on initial load
- Center chat column uses the freed width
- "Files" toggle is visible in the topbar
FAIL: Right panel starts open without any browsing or preview action.
### T5.1: File Tree Loads When Files Panel Is Opened
SETUP: Active session with workspace set.
STEPS:
1. Click the "Files" toggle in the topbar
EXPECT: EXPECT:
- Right panel shows "WORKSPACE" header - Right panel shows "WORKSPACE" header
- File tree lists files and directories in the workspace - File tree lists files and directories in the workspace
@@ -263,10 +287,11 @@ STEPS:
1. Click the X button in the panel header 1. Click the X button in the panel header
EXPECT: EXPECT:
- Preview closes - Preview closes
- File tree is visible again - If the panel auto-opened for that preview, the entire right panel closes again
- If the panel was manually opened for browsing first, the file tree is visible again
- Preview area is hidden - Preview area is hidden
- Reopening the same file shows fresh content (no stale cached text) - Reopening the same file shows fresh content (no stale cached text)
FAIL: X button does nothing, tree does not reappear. FAIL: X button does nothing, panel stays stuck open, or the file tree does not reappear after manual browse mode.
### T5.5: Preview an Image File (Sprint 2) ### T5.5: Preview an Image File (Sprint 2)
SETUP: Upload a PNG, JPG, or any image file to the workspace, OR the workspace already contains one. SETUP: Upload a PNG, JPG, or any image file to the workspace, OR the workspace already contains one.
@@ -377,7 +402,8 @@ FAIL: Command blocked after Allow once, card stays, error.
### T8.1: Download Conversation as Markdown ### T8.1: Download Conversation as Markdown
SETUP: A session with at least 2 messages (1 user + 1 assistant). SETUP: A session with at least 2 messages (1 user + 1 assistant).
STEPS: STEPS:
1. Click the "Transcript" download button in the sidebar bottom 1. Click the "Hermes" button in the sidebar footer
2. In the Control Center modal, click "Transcript"
EXPECT: EXPECT:
- Browser downloads a .md file named hermes-{session_id}.md - Browser downloads a .md file named hermes-{session_id}.md
- Opening the file shows the conversation in markdown format: - Opening the file shows the conversation in markdown format:
@@ -468,6 +494,7 @@ FAIL: No log output, log shows Apache-style text instead of JSON, log file not c
SETUP: Message is sending (thinking dots visible). SETUP: Message is sending (thinking dots visible).
EXPECT: EXPECT:
- Send button is visually grayed out - Send button is visually grayed out
- Stop button is visible in the composer footer
- Pressing Enter does NOT send another message - Pressing Enter does NOT send another message
- Clicking Send button does nothing - Clicking Send button does nothing
FAIL: Multiple messages sent while one is in flight. FAIL: Multiple messages sent while one is in flight.
@@ -831,7 +858,7 @@ FAIL: No icon ever appears, icon always visible (not hover-only).
### T21.2: Delete a File with Confirmation ### T21.2: Delete a File with Confirmation
STEPS: STEPS:
1. Hover over a file and click its trash icon 1. Hover over a file and click its trash icon
2. A browser confirm dialog appears: "Delete [filename]?" 2. An in-app confirmation modal appears: "Delete [filename]?"
3. Click OK 3. Click OK
EXPECT: EXPECT:
- Toast: "Deleted [filename]" - Toast: "Deleted [filename]"
@@ -842,7 +869,7 @@ FAIL: File not deleted, no confirmation dialog, error.
### T21.3: Cancel Delete Does Nothing ### T21.3: Cancel Delete Does Nothing
STEPS: STEPS:
1. Hover over a file and click its trash icon 1. Hover over a file and click its trash icon
2. Click Cancel on the confirm dialog 2. Click Cancel on the confirmation modal
EXPECT: EXPECT:
- File remains in the tree - File remains in the tree
- No toast, no error - No toast, no error
@@ -851,7 +878,7 @@ FAIL: File deleted despite cancel.
### T21.4: Create a New File ### T21.4: Create a New File
STEPS: STEPS:
1. Click the + button in the workspace panel header 1. Click the + button in the workspace panel header
2. A prompt dialog appears: "New file name (e.g. notes.md):" 2. An in-app input modal appears: "New file name (e.g. notes.md):"
3. Type "test-sprint4.md" and click OK 3. Type "test-sprint4.md" and click OK
EXPECT: EXPECT:
- Toast: "Created test-sprint4.md" - Toast: "Created test-sprint4.md"
@@ -925,7 +952,7 @@ FAIL: Invalid path added, no error.
### T22.4: Remove a Workspace ### T22.4: Remove a Workspace
STEPS: STEPS:
1. Click the X button next to any non-default workspace 1. Click the X button next to any non-default workspace
2. Confirm the dialog 2. Confirm the modal
EXPECT: EXPECT:
- Workspace disappears from the list - Workspace disappears from the list
- Toast: "Workspace removed" - Toast: "Workspace removed"
@@ -981,7 +1008,7 @@ STEPS:
1. Hover over an assistant message 1. Hover over an assistant message
2. Click the clipboard icon 2. Click the clipboard icon
EXPECT: EXPECT:
- Icon briefly shows a checkmark (✓) then reverts to clipboard - Icon briefly shows a check icon, then reverts to the copy icon
- Paste (Cmd+V) elsewhere shows the full text of that message - Paste (Cmd+V) elsewhere shows the full text of that message
FAIL: No visual feedback, clipboard empty or wrong content. FAIL: No visual feedback, clipboard empty or wrong content.
@@ -994,23 +1021,23 @@ STEPS:
1. Click any .py, .js, or .txt file in the workspace file tree 1. Click any .py, .js, or .txt file in the workspace file tree
EXPECT: EXPECT:
- File content shows in read-only monospace view - File content shows in read-only monospace view
- An "✎ Edit" button is visible in the preview path bar - An Edit button with a pencil icon is visible in the preview path bar
- Content is NOT editable (clicking in it does nothing) - Content is NOT editable (clicking in it does nothing)
FAIL: Content immediately editable, no Edit button. FAIL: Content immediately editable, no Edit button.
### T24.2: Edit Button Enters Edit Mode ### T24.2: Edit Button Enters Edit Mode
STEPS: STEPS:
1. Click "✎ Edit" on a code file preview 1. Click the Edit button on a code file preview
EXPECT: EXPECT:
- Read-only view replaced by an editable textarea - Read-only view replaced by an editable textarea
- Content of the file is pre-populated in the textarea - Content of the file is pre-populated in the textarea
- Button changes to "💾 Save" - Button changes to "Save" with a disk icon
FAIL: Nothing changes, button doesn't change. FAIL: Nothing changes, button doesn't change.
### T24.3: Save Writes Changes to Disk ### T24.3: Save Writes Changes to Disk
STEPS: STEPS:
1. In edit mode, change some text 1. In edit mode, change some text
2. Click "💾 Save" 2. Click the Save button
EXPECT: EXPECT:
- Read-only view returns, showing the updated content - Read-only view returns, showing the updated content
- Toast: "Saved" - Toast: "Saved"
@@ -1022,8 +1049,8 @@ STEPS:
1. Enter edit mode on a file 1. Enter edit mode on a file
2. Make any change (type a character) 2. Make any change (type a character)
EXPECT: EXPECT:
- Button shows "💾 Save*" (asterisk indicates unsaved changes) - Button shows "Save*" with the disk icon still visible (asterisk indicates unsaved changes)
FAIL: No asterisk, button stays as "💾 Save". FAIL: No asterisk, button stays as "Save".
### T24.5: Markdown File Edit-Save Roundtrip ### T24.5: Markdown File Edit-Save Roundtrip
STEPS: STEPS:
@@ -1070,7 +1097,7 @@ against each criterion below. A Claude browser agent can verify these with brows
### T25.1: Sidebar Nav Tabs are Icon-Only ### T25.1: Sidebar Nav Tabs are Icon-Only
EXPECT: EXPECT:
- Five icon-only tabs in the sidebar nav row: 💬 ⏱️ 📚 🧠 📁 - Five icon-only tabs in the sidebar nav row: message, clock, book, brain, folder
- No text labels visible by default (text removed to prevent overflow) - No text labels visible by default (text removed to prevent overflow)
- Hovering a tab shows a tooltip with the label (Chat/Tasks/Skills/Memory/Spaces) - Hovering a tab shows a tooltip with the label (Chat/Tasks/Skills/Memory/Spaces)
- Active tab has a blue underline, icon brighter blue - Active tab has a blue underline, icon brighter blue
@@ -1194,7 +1221,7 @@ STEPS:
3. Click Create job 3. Click Create job
EXPECT: EXPECT:
- Form closes - Form closes
- Toast: "Job created" - Toast: "Job created"
- New job appears in the cron list with status "active" - New job appears in the cron list with status "active"
FAIL: Error shown, job not created, form stays open. FAIL: Error shown, job not created, form stays open.
@@ -1225,7 +1252,8 @@ FAIL: Job created, form doesn't close.
### T28.1: JSON Export Button Downloads File ### T28.1: JSON Export Button Downloads File
SETUP: Active session with at least a few messages. SETUP: Active session with at least a few messages.
STEPS: STEPS:
1. Click the "JSON" button in the sidebar footer (next to Transcript) 1. Click the "Hermes" button in the sidebar footer
2. In the Control Center modal, click "JSON"
EXPECT: EXPECT:
- Browser downloads a file named hermes-{session_id}.json - Browser downloads a file named hermes-{session_id}.json
- Opening the file shows valid JSON with: session_id, title, messages array, - Opening the file shows valid JSON with: session_id, title, messages array,
@@ -1289,7 +1317,7 @@ STEPS (continued from T29.1):
1. Change the name field to "Renamed Job" 1. Change the name field to "Renamed Job"
2. Click Save 2. Click Save
EXPECT: EXPECT:
- Form closes, toast "Job updated" - Form closes, toast "Job updated"
- Job header shows new name - Job header shows new name
FAIL: Save fails, name unchanged. FAIL: Save fails, name unchanged.
@@ -1297,7 +1325,7 @@ FAIL: Save fails, name unchanged.
SETUP: A cron job you can safely delete (or a test job created for this). SETUP: A cron job you can safely delete (or a test job created for this).
STEPS: STEPS:
1. Expand the job, click "Delete" 1. Expand the job, click "Delete"
2. Confirm the dialog 2. Confirm the modal
EXPECT: EXPECT:
- Toast: "Job deleted" - Toast: "Job deleted"
- Job disappears from the list - Job disappears from the list
@@ -1326,7 +1354,7 @@ tags: [test]
# Test" # Test"
2. Click Save skill 2. Click Save skill
EXPECT: EXPECT:
- Toast "Skill created", form closes - Toast "Skill created", form closes
- Skill appears in the skills list - Skill appears in the skills list
FAIL: Error, skill not in list. FAIL: Error, skill not in list.
@@ -1354,7 +1382,7 @@ STEPS:
1. In edit mode, add a line to the textarea 1. In edit mode, add a line to the textarea
2. Click Save 2. Click Save
EXPECT: EXPECT:
- Toast "Memory saved", form closes - Toast "Memory saved", form closes
- Memory panel reloads showing the updated content - Memory panel reloads showing the updated content
FAIL: Save fails, content unchanged. FAIL: Save fails, content unchanged.
@@ -1467,14 +1495,16 @@ FAIL: Both messages removed, wrong message sent, crash.
### T34.1: Clear Button Appears When Session Has Messages ### T34.1: Clear Button Appears When Session Has Messages
SETUP: Session with at least one message. SETUP: Session with at least one message.
EXPECT: EXPECT:
- A "🗑 Clear" chip appears in the topbar right side (next to the workspace chip) - The "Hermes" button is visible in the sidebar footer
- Button NOT visible when session has no messages / empty state - Opening the Control Center shows a "Clear" action in the Conversation section
- The Clear action is disabled when there is no active session or no messages
FAIL: Button always visible, never visible. FAIL: Button always visible, never visible.
### T34.2: Clear Wipes Messages and Resets Title ### T34.2: Clear Wipes Messages and Resets Title
STEPS: STEPS:
1. Click the Clear button in the topbar 1. Click the "Hermes" button in the sidebar footer
2. Confirm the dialog 2. Click "Clear" in the Conversation section
3. Confirm the modal
EXPECT: EXPECT:
- All messages disappear from the chat area - All messages disappear from the chat area
- Empty state ("What can I help with?") reappears - Empty state ("What can I help with?") reappears
@@ -1485,7 +1515,7 @@ FAIL: Session deleted, messages remain, title not reset.
### T34.3: Cancel Clear Does Nothing ### T34.3: Cancel Clear Does Nothing
STEPS: STEPS:
1. Click Clear, then click Cancel in the confirm dialog 1. Click Clear, then click Cancel in the confirmation modal
EXPECT: EXPECT:
- All messages still present - All messages still present
- No toast, no change - No toast, no change
@@ -1609,7 +1639,7 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
- Switch model. Send a message. Verify response uses selected model. - Switch model. Send a message. Verify response uses selected model.
### Sprint 12: Settings + Pin + Import ### Sprint 12: Settings + Pin + Import
- Click gear icon. Settings overlay opens. - Click the "Hermes WebUI" button in the sidebar footer. Control Center overlay opens with vertical section tabs on the left.
- Change default model, save. Restart server. Verify setting persisted. - Change default model, save. Restart server. Verify setting persisted.
- Pin a session (star icon in hover overlay). Verify it floats to top of list. - Pin a session (star icon in hover overlay). Verify it floats to top of list.
- Export session as JSON. Import it back. Verify messages restored. - Export session as JSON. Import it back. Verify messages restored.
@@ -1637,11 +1667,12 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
### Sprint 16: Sidebar Visual Polish ### Sprint 16: Sidebar Visual Polish
- Session titles use full sidebar width (no truncated space for hidden icons). - Session titles use full sidebar width (no truncated space for hidden icons).
- Hover a session → action buttons appear from right with gradient fade. - Hover a session → a dotted actions trigger appears on the right.
- Click the dotted trigger → a dropdown opens with pin, project, archive, duplicate, and delete actions.
- All icons are monochrome SVGs (not emoji). Consistent across platforms. - All icons are monochrome SVGs (not emoji). Consistent across platforms.
- Pinned sessions show small gold star inline. Unpinned = no star, full title width. - Pinned sessions show small gold star inline. Unpinned = no star, full title width.
- Active session has gold highlight (not blue). Overlay gradient matches. - Active session has gold highlight (not blue).
- Double-click to rename → overlay hides during rename. - Double-click to rename → session actions hide during rename.
### Sprint 17: Workspace + Slash Commands + Send Key ### Sprint 17: Workspace + Slash Commands + Send Key
- Navigate into a subdirectory. Breadcrumb bar appears with clickable segments. - Navigate into a subdirectory. Breadcrumb bar appears with clickable segments.
@@ -1702,7 +1733,7 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
- "Use" button switches profile. Delete button removes non-default profiles. - "Use" button switches profile. Delete button removes non-default profiles.
- "+ New profile" form: name validation (lowercase + hyphens), clone config checkbox. - "+ New profile" form: name validation (lowercase + hyphens), clone config checkbox.
- Create profile → appears in list and dropdown. - Create profile → appears in list and dropdown.
- Delete profile → confirm dialog. Auto-switches to default if deleting active. - Delete profile → confirmation modal. Auto-switches to default if deleting active.
- Attempt switch while agent busy → blocked with toast message. - Attempt switch while agent busy → blocked with toast message.
- With hermes-agent not installed → only default profile shown, graceful fallback. - With hermes-agent not installed → only default profile shown, graceful fallback.

View File

@@ -5,16 +5,120 @@ async function cancelStream(){
await fetch(new URL(`/api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{credentials:'include'}); await fetch(new URL(`/api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{credentials:'include'});
}catch(e){/* cancel request failed — cleanup below still runs */} }catch(e){/* cancel request failed — cleanup below still runs */}
// Clear status unconditionally after the cancel request completes. // Clear status unconditionally after the cancel request completes.
// The SSE cancel event may also fire and call setBusy(false)/setStatus(''), // The SSE cancel event may also fire, but if the connection is already
// but if the connection is already closed it won't arrive — so we handle // closed it won't arrive — so we handle cleanup here as the guaranteed path.
// cleanup here as the guaranteed path.
const btn=$('btnCancel');if(btn)btn.style.display='none'; const btn=$('btnCancel');if(btn)btn.style.display='none';
S.activeStreamId=null; S.activeStreamId=null;
setBusy(false); setBusy(false);
setStatus(''); if(typeof setComposerStatus==='function') setComposerStatus('');
else setStatus('');
} }
// ── Mobile navigation ────────────────────────────────────────────────────── // ── Mobile navigation ──────────────────────────────────────────────────────
let _workspacePanelMode='closed'; // 'closed' | 'browse' | 'preview'
function _isCompactWorkspaceViewport(){
return window.matchMedia('(max-width: 900px)').matches;
}
function _workspacePanelEls(){
return {
layout: document.querySelector('.layout'),
panel: document.querySelector('.rightpanel'),
toggleBtn: $('btnWorkspacePanelToggle'),
collapseBtn: $('btnCollapseWorkspacePanel'),
};
}
function _hasWorkspacePreviewVisible(){
const preview=$('previewArea');
return !!(preview&&preview.classList.contains('visible'));
}
function _setWorkspacePanelMode(mode){
const {layout,panel}= _workspacePanelEls();
if(!layout||!panel)return;
_workspacePanelMode=(mode==='browse'||mode==='preview')?mode:'closed';
const open=_workspacePanelMode!=='closed';
layout.classList.toggle('workspace-panel-collapsed',!open);
if(_isCompactWorkspaceViewport()){
panel.classList.toggle('mobile-open',open);
}else{
panel.classList.remove('mobile-open');
}
syncWorkspacePanelUI();
}
function syncWorkspacePanelState(){
const hasPreview=_hasWorkspacePreviewVisible();
if(hasPreview){
if(_workspacePanelMode==='closed') _setWorkspacePanelMode('preview');
else syncWorkspacePanelUI();
return;
}
if(!S.session){
_setWorkspacePanelMode('closed');
return;
}
_setWorkspacePanelMode(_workspacePanelMode==='preview'?'closed':_workspacePanelMode);
}
function openWorkspacePanel(mode='browse'){
if(mode==='browse'&&!S.session&&!_hasWorkspacePreviewVisible())return;
if(mode==='preview'&&_workspacePanelMode==='browse'){
syncWorkspacePanelUI();
return;
}
_setWorkspacePanelMode(mode);
}
function closeWorkspacePanel(){
_setWorkspacePanelMode('closed');
}
function ensureWorkspacePreviewVisible(){
if(_workspacePanelMode==='closed') _setWorkspacePanelMode('preview');
else syncWorkspacePanelUI();
}
function handleWorkspaceClose(){
if(_hasWorkspacePreviewVisible()){
clearPreview();
return;
}
closeWorkspacePanel();
}
function syncWorkspacePanelUI(){
const {layout,panel,toggleBtn,collapseBtn}= _workspacePanelEls();
if(!layout||!panel)return;
const desktopOpen=_workspacePanelMode!=='closed';
const mobileOpen=panel.classList.contains('mobile-open');
const isCompact=_isCompactWorkspaceViewport();
const isOpen=isCompact?mobileOpen:desktopOpen;
const canBrowse=!!S.session||_hasWorkspacePreviewVisible();
const hasPreview=_hasWorkspacePreviewVisible();
if(toggleBtn){
toggleBtn.classList.toggle('active',isOpen);
toggleBtn.setAttribute('aria-pressed',isOpen?'true':'false');
toggleBtn.title=isOpen?'Hide workspace panel':'Show workspace panel';
toggleBtn.disabled=!canBrowse;
}
if(collapseBtn){
collapseBtn.title=isCompact?'Close workspace panel':'Hide workspace panel';
}
const hasSession=!!S.session;
['btnUpDir','btnNewFile','btnNewFolder','btnRefreshPanel'].forEach(id=>{
const el=$(id);
if(el)el.disabled=!hasSession;
});
const clearBtn=$('btnClearPreview');
if(clearBtn){
clearBtn.disabled=!isOpen;
clearBtn.title=hasPreview?'Close preview':'Hide workspace panel';
}
}
function toggleMobileSidebar(){ function toggleMobileSidebar(){
const sidebar=document.querySelector('.sidebar'); const sidebar=document.querySelector('.sidebar');
const overlay=$('mobileOverlay'); const overlay=$('mobileOverlay');
@@ -27,37 +131,22 @@ function closeMobileSidebar(){
const sidebar=document.querySelector('.sidebar'); const sidebar=document.querySelector('.sidebar');
const overlay=$('mobileOverlay'); const overlay=$('mobileOverlay');
if(sidebar)sidebar.classList.remove('mobile-open'); if(sidebar)sidebar.classList.remove('mobile-open');
// only hide overlay if right panel is also closed if(overlay)overlay.classList.remove('visible');
const panel=document.querySelector('.rightpanel');
if(!panel||!panel.classList.contains('mobile-open')){
if(overlay)overlay.classList.remove('visible');
}
} }
function toggleMobileFiles(){ function toggleMobileFiles(){
const panel=document.querySelector('.rightpanel'); toggleWorkspacePanel();
const overlay=$('mobileOverlay');
if(!panel)return;
if(panel.classList.contains('mobile-open')){
panel.classList.remove('mobile-open');
// only hide overlay if left sidebar is also closed
const sidebar=document.querySelector('.sidebar');
if(!sidebar||!sidebar.classList.contains('mobile-open')){
if(overlay)overlay.classList.remove('visible');
}
} else {
panel.classList.add('mobile-open');
if(overlay)overlay.classList.add('visible');
}
} }
function closeMobileFiles(){ function toggleWorkspacePanel(force){
const panel=document.querySelector('.rightpanel'); const {panel}= _workspacePanelEls();
const overlay=$('mobileOverlay'); if(!panel)return;
if(panel)panel.classList.remove('mobile-open'); const currentlyOpen=_workspacePanelMode!=='closed';
// only hide overlay if left sidebar is also closed const nextOpen=typeof force==='boolean'?force:!currentlyOpen;
const sidebar=document.querySelector('.sidebar'); if(!nextOpen){
if(!sidebar||!sidebar.classList.contains('mobile-open')){ closeWorkspacePanel();
if(overlay)overlay.classList.remove('visible'); return;
} }
const nextMode=_hasWorkspacePreviewVisible()?'preview':'browse';
openWorkspacePanel(nextMode);
} }
function mobileSwitchPanel(name){ function mobileSwitchPanel(name){
// Switch the panel content view // Switch the panel content view
@@ -191,6 +280,8 @@ $('importFileInput').onchange=async(e)=>{
if(res.ok&&res.session){ if(res.ok&&res.session){
await loadSession(res.session.session_id); await loadSession(res.session.session_id);
await renderSessionList(); await renderSessionList();
const overlay=$('settingsOverlay');
if(overlay) overlay.style.display='none';
showToast(t('session_imported')); showToast(t('session_imported'));
} }
}catch(err){ }catch(err){
@@ -199,6 +290,7 @@ $('importFileInput').onchange=async(e)=>{
}; };
// btnRefreshFiles is now panel-icon-btn in header (see HTML) // btnRefreshFiles is now panel-icon-btn in header (see HTML)
function clearPreview(){ function clearPreview(){
const closePanelAfter=_workspacePanelMode==='preview';
const pa=$('previewArea');if(pa)pa.classList.remove('visible'); const pa=$('previewArea');if(pa)pa.classList.remove('visible');
const pi=$('previewImg');if(pi){pi.onerror=null;pi.src='';} const pi=$('previewImg');if(pi){pi.onerror=null;pi.src='';}
const pm=$('previewMd');if(pm)pm.innerHTML=''; const pm=$('previewMd');if(pm)pm.innerHTML='';
@@ -208,15 +300,20 @@ function clearPreview(){
_previewCurrentPath='';_previewCurrentMode='';_previewDirty=false; _previewCurrentPath='';_previewCurrentMode='';_previewDirty=false;
// Restore directory breadcrumb after closing file preview // Restore directory breadcrumb after closing file preview
if(typeof renderBreadcrumb==='function') renderBreadcrumb(); if(typeof renderBreadcrumb==='function') renderBreadcrumb();
if(closePanelAfter)closeWorkspacePanel();
else syncWorkspacePanelUI();
} }
$('btnClearPreview').onclick=clearPreview; $('btnClearPreview').onclick=handleWorkspaceClose;
// workspacePath click handler removed -- use topbar workspace chip dropdown instead // workspacePath click handler removed -- use topbar workspace chip dropdown instead
$('modelSelect').onchange=async()=>{ $('modelSelect').onchange=async()=>{
if(!S.session)return; if(!S.session)return;
const selectedModel=$('modelSelect').value; const selectedModel=$('modelSelect').value;
if(typeof closeModelDropdown==='function') closeModelDropdown();
localStorage.setItem('hermes-webui-model', selectedModel); localStorage.setItem('hermes-webui-model', selectedModel);
await api('/api/session/update',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,workspace:S.session.workspace,model:selectedModel})}); await api('/api/session/update',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,workspace:S.session.workspace,model:selectedModel})});
S.session.model=selectedModel;syncTopbar(); S.session.model=selectedModel;
if(typeof syncModelChip==='function') syncModelChip();
syncTopbar();
// Warn if selected model belongs to a different provider than what Hermes is configured for // Warn if selected model belongs to a different provider than what Hermes is configured for
if(typeof _checkProviderMismatch==='function'){ if(typeof _checkProviderMismatch==='function'){
const warn=_checkProviderMismatch(selectedModel); const warn=_checkProviderMismatch(selectedModel);
@@ -306,6 +403,10 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
btn.onclick=()=>{$('msg').value=btn.dataset.msg;send();}; btn.onclick=()=>{$('msg').value=btn.dataset.msg;send();};
}); });
window.addEventListener('resize',()=>{
syncWorkspacePanelState();
});
// Boot: restore last session or start fresh // Boot: restore last session or start fresh
// ── Resizable panels ────────────────────────────────────────────────────── // ── Resizable panels ──────────────────────────────────────────────────────
(function(){ (function(){
@@ -399,13 +500,14 @@ function applyBotName(){
_initResizePanels(); _initResizePanels();
const saved=localStorage.getItem('hermes-webui-session'); const saved=localStorage.getItem('hermes-webui-session');
if(saved){ if(saved){
try{await loadSession(saved);await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;} try{await loadSession(saved);syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;}
catch(e){localStorage.removeItem('hermes-webui-session');} catch(e){localStorage.removeItem('hermes-webui-session');}
} }
// no saved session - show empty state, wait for user to hit + // no saved session - show empty state, wait for user to hit +
syncTopbar();
syncWorkspacePanelState();
$('emptyState').style.display=''; $('emptyState').style.display='';
await renderSessionList(); await renderSessionList();
// Start real-time gateway session sync if setting is enabled // Start real-time gateway session sync if setting is enabled
if(typeof startGatewaySSE==='function') startGatewaySSE(); if(typeof startGatewaySSE==='function') startGatewaySSE();
})(); })();

View File

@@ -86,13 +86,8 @@ async function cmdWorkspace(args){
(w.name||'').toLowerCase().includes(q)||w.path.toLowerCase().includes(q) (w.name||'').toLowerCase().includes(q)||w.path.toLowerCase().includes(q)
); );
if(!ws){showToast(t('no_workspace_match')+`"${args}"`);return;} if(!ws){showToast(t('no_workspace_match')+`"${args}"`);return;}
if(!S.session)return; if(typeof switchToWorkspace==='function') await switchToWorkspace(ws.path, ws.name||ws.path);
await api('/api/session/update',{method:'POST',body:JSON.stringify({ else showToast(t('switched_workspace')+(ws.name||ws.path));
session_id:S.session.session_id,workspace:ws.path,model:S.session.model
})});
S.session.workspace=ws.path;
syncTopbar();await loadDir('.');
showToast(t('switched_workspace')+(ws.name||ws.path));
}catch(e){showToast(t('workspace_switch_failed')+e.message);} }catch(e){showToast(t('workspace_switch_failed')+e.message);}
} }

View File

@@ -87,10 +87,17 @@ const LOCALES = {
failed_colon: 'Failed: ', failed_colon: 'Failed: ',
// ui.js // ui.js
no_workspace: 'No workspace', no_workspace: 'No workspace',
dialog_confirm_title: 'Confirm action',
dialog_prompt_title: 'Enter a value',
dialog_confirm_btn: 'Confirm',
// workspace.js // workspace.js
unsaved_confirm: 'You have unsaved changes in the preview. Discard and navigate?', unsaved_confirm: 'You have unsaved changes in the preview. Discard and navigate?',
discard: 'Discard',
save: 'Save', save: 'Save',
edit: 'Edit', edit: 'Edit',
clear: 'Clear',
create: 'Create',
remove: 'Remove',
save_title: 'Save changes', save_title: 'Save changes',
edit_title: 'Edit this file', edit_title: 'Edit this file',
saved: 'Saved', saved: 'Saved',
@@ -106,6 +113,7 @@ const LOCALES = {
deleted: 'Deleted ', deleted: 'Deleted ',
delete_failed: 'Delete failed: ', delete_failed: 'Delete failed: ',
new_file_prompt: 'New file name (e.g. notes.md):', new_file_prompt: 'New file name (e.g. notes.md):',
project_name_prompt: 'Project name:',
created: 'Created ', created: 'Created ',
create_failed: 'Create failed: ', create_failed: 'Create failed: ',
new_folder_prompt: 'New folder name:', new_folder_prompt: 'New folder name:',
@@ -607,10 +615,17 @@ const LOCALES = {
failed_colon: 'Fehlgeschlagen: ', failed_colon: 'Fehlgeschlagen: ',
// ui.js // ui.js
no_workspace: 'Kein Workspace', no_workspace: 'Kein Workspace',
dialog_confirm_title: 'Aktion bestätigen',
dialog_prompt_title: 'Wert eingeben',
dialog_confirm_btn: 'Bestätigen',
// workspace.js // workspace.js
unsaved_confirm: 'Sie haben ungespeicherte Änderungen in der Vorschau. Verwerfen und fortfahren?', unsaved_confirm: 'Sie haben ungespeicherte Änderungen in der Vorschau. Verwerfen und fortfahren?',
discard: 'Verwerfen',
save: 'Speichern', save: 'Speichern',
edit: 'Bearbeiten', edit: 'Bearbeiten',
clear: 'Leeren',
create: 'Erstellen',
remove: 'Entfernen',
save_title: 'Änderungen speichern', save_title: 'Änderungen speichern',
edit_title: 'Diese Datei bearbeiten', edit_title: 'Diese Datei bearbeiten',
saved: 'Gespeichert', saved: 'Gespeichert',
@@ -626,6 +641,7 @@ const LOCALES = {
deleted: 'Gelöscht ', deleted: 'Gelöscht ',
delete_failed: 'Löschen fehlgeschlagen: ', delete_failed: 'Löschen fehlgeschlagen: ',
new_file_prompt: 'Neuer Dateiname (z.B. notes.md):', new_file_prompt: 'Neuer Dateiname (z.B. notes.md):',
project_name_prompt: 'Projektname:',
created: 'Erstellt ', created: 'Erstellt ',
create_failed: 'Erstellen fehlgeschlagen: ', create_failed: 'Erstellen fehlgeschlagen: ',
new_folder_prompt: 'Neuer Ordnername:', new_folder_prompt: 'Neuer Ordnername:',
@@ -798,10 +814,17 @@ const LOCALES = {
failed_colon: '\u5931\u8d25\uff1a', failed_colon: '\u5931\u8d25\uff1a',
// ui.js // ui.js
no_workspace: '\u672a\u9009\u62e9\u5de5\u4f5c\u533a', no_workspace: '\u672a\u9009\u62e9\u5de5\u4f5c\u533a',
dialog_confirm_title: '\u786e\u8ba4\u64cd\u4f5c',
dialog_prompt_title: '\u8f93\u5165\u5185\u5bb9',
dialog_confirm_btn: '\u786e\u8ba4',
// workspace.js // workspace.js
unsaved_confirm: '\u9884\u89c8\u533a\u6709\u672a\u4fdd\u5b58\u4fee\u6539\uff0c\u8981\u653e\u5f03\u66f4\u6539\u5e76\u7ee7\u7eed\u8df3\u8f6c\u5417\uff1f', unsaved_confirm: '\u9884\u89c8\u533a\u6709\u672a\u4fdd\u5b58\u4fee\u6539\uff0c\u8981\u653e\u5f03\u66f4\u6539\u5e76\u7ee7\u7eed\u8df3\u8f6c\u5417\uff1f',
discard: '\u653e\u5f03',
save: '\u4fdd\u5b58', save: '\u4fdd\u5b58',
edit: '\u7f16\u8f91', edit: '\u7f16\u8f91',
clear: '\u6e05\u7a7a',
create: '\u521b\u5efa',
remove: '\u79fb\u9664',
save_title: '\u4fdd\u5b58\u4fee\u6539', save_title: '\u4fdd\u5b58\u4fee\u6539',
edit_title: '\u7f16\u8f91\u6b64\u6587\u4ef6', edit_title: '\u7f16\u8f91\u6b64\u6587\u4ef6',
saved: '\u5df2\u4fdd\u5b58', saved: '\u5df2\u4fdd\u5b58',
@@ -817,6 +840,7 @@ const LOCALES = {
deleted: '\u5df2\u5220\u9664 ', deleted: '\u5df2\u5220\u9664 ',
delete_failed: '\u5220\u9664\u5931\u8d25\uff1a', delete_failed: '\u5220\u9664\u5931\u8d25\uff1a',
new_file_prompt: '\u65b0\u6587\u4ef6\u540d\uff08\u4f8b\u5982 notes.md\uff09\uff1a', new_file_prompt: '\u65b0\u6587\u4ef6\u540d\uff08\u4f8b\u5982 notes.md\uff09\uff1a',
project_name_prompt: '\u9879\u76ee\u540d\u79f0\uff1a',
created: '\u5df2\u521b\u5efa ', created: '\u5df2\u521b\u5efa ',
create_failed: '\u521b\u5efa\u5931\u8d25\uff1a', create_failed: '\u521b\u5efa\u5931\u8d25\uff1a',
new_folder_prompt: '\u65b0\u6587\u4ef6\u5939\u540d\u79f0\uff1a', new_folder_prompt: '\u65b0\u6587\u4ef6\u5939\u540d\u79f0\uff1a',

View File

@@ -14,7 +14,9 @@ const LI_PATHS = {
'list-todo': '<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>', 'list-todo': '<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
// Editing / actions // Editing / actions
'pencil': '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>', 'pencil': '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
'chevron-down': '<polyline points="6 9 12 15 18 9"/>', 'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>', 'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>', 'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
'braces': '<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/>', 'braces': '<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/>',
@@ -29,7 +31,9 @@ const LI_PATHS = {
'square': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>', 'square': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>',
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>', 'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
'arrow-up': '<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>', 'arrow-up': '<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>',
'arrow-right': '<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>',
'loader': '<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/>', 'loader': '<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/>',
'pause': '<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
// Tool icons // Tool icons
'terminal': '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>', 'terminal': '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>', 'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
@@ -44,6 +48,10 @@ const LI_PATHS = {
'bot': '<rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/>', 'bot': '<rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/>',
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>', 'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
'shuffle': '<polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/>', 'shuffle': '<polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/>',
'paperclip': '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.82-2.82l8.48-8.48"/>',
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
'rotate-ccw': '<path d="M3 2v6h6"/><path d="M3 8a9 9 0 1 0 2.64-4.36L3 8"/>',
'user': '<path d="M20 21a8 8 0 0 0-16 0"/><circle cx="12" cy="7" r="4"/>',
// File-type icons // File-type icons
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>', 'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
'file-code': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><polyline points="10 13 8 15 10 17"/><polyline points="14 13 16 15 14 17"/>', 'file-code': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><polyline points="10 13 8 15 10 17"/><polyline points="14 13 16 15 14 17"/>',

View File

@@ -14,7 +14,7 @@
<body> <body>
<div class="layout"> <div class="layout">
<aside class="sidebar"> <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.49.4</div></div></div>
<div class="sidebar-nav"> <div class="sidebar-nav">
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button> <button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button> <button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
@@ -29,7 +29,7 @@
<div class="sidebar-section"> <div class="sidebar-section">
<button class="new-chat-btn" id="btnNewChat"> <button class="new-chat-btn" id="btnNewChat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span data-i18n="new_conversation">New conversation</span> <span style="font-size:10px;opacity:.5;margin-left:4px">&#8984;K</span> <span data-i18n="new_conversation">New conversation</span> <span style="font-size:10px;opacity:.5;margin-left:4px">Cmd+K</span>
</button> </button>
</div> </div>
<div class="session-search"><input id="sessionSearch" placeholder="Filter conversations..." data-i18n-placeholder="filter_conversations" oninput="filterSessions()"></div> <div class="session-search"><input id="sessionSearch" placeholder="Filter conversations..." data-i18n-placeholder="filter_conversations" oninput="filterSessions()"></div>
@@ -134,42 +134,30 @@
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="profilesPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div> <div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="profilesPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
</div> </div>
<div class="sidebar-bottom"> <div class="sidebar-bottom">
<div class="field-label" style="font-size:10px;letter-spacing:.07em;margin-bottom:4px">MODEL</div> <button class="hermes-launch-btn" id="btnHermesPanel" onclick="toggleSettings()" title="Open Hermes control center">
<select id="modelSelect"> <span class="hermes-launch-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<optgroup label="OpenAI"> <defs>
<option value="openai/gpt-5.4-mini">GPT-5.4 Mini</option> <linearGradient id="hermes-gold-sidebar" x1="0%" y1="0%" x2="0%" y2="100%">
<option value="openai/gpt-4o">GPT-4o</option> <stop offset="0%" style="stop-color:#F5C542;stop-opacity:1"/>
<option value="openai/o3">o3</option> <stop offset="100%" style="stop-color:#D4961C;stop-opacity:1"/>
<option value="openai/o4-mini">o4-mini</option> </linearGradient>
</optgroup> </defs>
<optgroup label="Anthropic"> <rect x="30" y="10" width="4" height="46" rx="2" fill="url(#hermes-gold-sidebar)"/>
<option value="anthropic/claude-sonnet-4.6">Claude Sonnet 4.6</option> <path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill="#F5C542" opacity="0.9"/>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option> <path d="M30 22 C26 19, 18 19, 14 22 C18 20, 24 20, 28 24" fill="#D4961C" opacity="0.8"/>
<option value="anthropic/claude-haiku-3-5">Claude Haiku 3.5</option> <path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill="#F5C542" opacity="0.9"/>
</optgroup> <path d="M34 22 C38 19, 46 19, 50 22 C46 20, 40 20, 36 24" fill="#D4961C" opacity="0.8"/>
<optgroup label="Other"> <path d="M32 48 C22 44, 20 38, 26 34 C20 36, 18 42, 24 46 C18 40, 22 30, 30 28 C24 32, 22 38, 28 42" fill="none" stroke="#F5C542" stroke-width="2.5" stroke-linecap="round"/>
<option value="google/gemini-2.5-pro">Gemini 2.5 Pro</option> <path d="M32 48 C42 44, 44 38, 38 34 C44 36, 46 42, 40 46 C46 40, 42 30, 34 28 C40 32, 42 38, 36 42" fill="none" stroke="#D4961C" stroke-width="2.5" stroke-linecap="round"/>
<option value="deepseek/deepseek-chat-v3-0324">DeepSeek V3</option> <circle cx="32" cy="10" r="4" fill="#F5C542"/>
<option value="meta-llama/llama-4-scout">Llama 4 Scout</option> <circle cx="32" cy="10" r="2" fill="#FFF8E1" opacity="0.7"/>
</optgroup> </svg></span>
</select> <span class="hermes-launch-copy">
<div style="position:relative"> <span class="hermes-launch-title">Hermes WebUI</span>
<div id="sidebarWsDisplay" style="display:flex;align-items:center;gap:7px;padding:0 0 8px;cursor:pointer;border-radius:8px;transition:background .15s" onclick="toggleWsDropdown()" title="Switch workspace"> <span class="hermes-launch-meta">Preferences, imports, exports</span>
<span style="opacity:.7;line-height:1"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span> </span>
<div style="min-width:0;flex:1"> <span class="hermes-launch-chevron" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></span>
<div style="font-size:11px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" id="sidebarWsName">Workspace</div> </button>
<div style="font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:1px" id="sidebarWsPath"></div>
</div>
<span style="color:var(--muted);flex-shrink:0;line-height:1"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></span>
</div>
<div class="ws-dropdown" id="wsDropdown"></div>
</div>
<div class="sidebar-actions">
<button class="sm-btn" id="btnDownload" title="Download as Markdown" data-i18n-title="download_transcript"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <span data-i18n="transcript">Transcript</span></button>
<button class="sm-btn" id="btnExportJSON" title="Export full session as JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg> JSON</button>
<button class="sm-btn" id="btnImportJSON" title="Import session from JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> <span data-i18n="import">Import</span></button>
<input type="file" id="importFileInput" accept=".json" style="display:none">
</div>
</div> </div>
<div class="resize-handle" id="sidebarResize"></div> <div class="resize-handle" id="sidebarResize"></div>
</aside> </aside>
@@ -180,15 +168,7 @@
</button> </button>
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta" data-i18n="new_conversation">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" data-i18n="new_conversation">Start a new conversation</div></div>
<div class="topbar-chips"> <div class="topbar-chips">
<div id="profileChipWrap" style="position:relative"> <button class="chip workspace-toggle-btn" id="btnWorkspacePanelToggle" onclick="toggleWorkspacePanel()" title="Show workspace panel" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg><span class="workspace-toggle-label">Files</span></button>
<div class="chip profile-chip" id="profileChip" onclick="toggleProfileDropdown()" title="Switch profile" style="cursor:pointer"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span id="profileChipLabel">default</span> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></div>
<div class="profile-dropdown" id="profileDropdown"></div>
</div>
<div class="chip model" id="modelChip">GPT-5.4 Mini</div>
<button class="chip clear-btn" id="btnClearConv" onclick="clearConversation()" title="Clear all messages in this conversation" style="display:none"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg> <span data-i18n="copy">Clear</span></button>
<button class="chip gear-btn" id="btnSettings" onclick="toggleSettings()" title="Settings" data-i18n-title="settings_title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
<button class="chip mobile-files-btn" id="btnMobileFiles" onclick="toggleMobileFiles()" title="Files"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
</div> </div>
</div> </div>
<div class="messages" id="messages"> <div class="messages" id="messages">
@@ -264,20 +244,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Activity bar: shows tool progress / status above composer (not inside input) -->
<div id="activityBar" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;">
<div id="activityBarInner" style="display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:8px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.07);font-size:12px;color:var(--muted);animation:fadeIn .15s ease;">
<span id="activityIcon" style="opacity:.6;line-height:1"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/></svg></span>
<span id="activityText" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
<button id="btnCancel" onclick="cancelStream()" style="display:none;background:rgba(233,69,96,.12);border:1px solid rgba(233,69,96,.35);color:#e94560;font-size:11px;font-weight:600;padding:3px 10px;border-radius:6px;cursor:pointer;flex-shrink:0;transition:background .15s;align-items:center;gap:4px" title="Cancel this task"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/></svg> Cancel</button>
<button id="btnDismissStatus" onclick="setStatus('')" style="display:none;background:none;border:none;color:var(--muted);line-height:1;cursor:pointer;padding:0 2px;opacity:.5;flex-shrink:0" title="Dismiss"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<span id="activityDots" style="display:flex;gap:3px;align-items:center">
<span style="width:4px;height:4px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out infinite"></span>
<span style="width:4px;height:4px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out .22s infinite"></span>
<span style="width:4px;height:4px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out .44s infinite"></span>
</span>
</div>
</div>
<div class="composer-wrap" id="composerWrap"> <div class="composer-wrap" id="composerWrap">
<div class="cmd-dropdown" id="cmdDropdown"></div> <div class="cmd-dropdown" id="cmdDropdown"></div>
<div class="composer-box" id="composerBox"> <div class="composer-box" id="composerBox">
@@ -302,16 +268,77 @@
<line x1="8" y1="23" x2="16" y2="23"/> <line x1="8" y1="23" x2="16" y2="23"/>
</svg> </svg>
</button> </button>
</div> <div class="composer-divider" aria-hidden="true"></div>
<div class="ctx-indicator" id="ctxIndicator" style="display:none" title="Context window usage"> <div id="profileChipWrap" class="composer-profile-wrap">
<span class="ctx-bar-wrap"><span class="ctx-bar" id="ctxBar"></span></span> <button class="composer-profile-chip profile-chip" id="profileChip" type="button" onclick="toggleProfileDropdown()" title="Switch profile">
<span class="ctx-label" id="ctxLabel"></span> <span class="composer-profile-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span>
<span class="composer-profile-label" id="profileChipLabel">default</span>
<span class="composer-profile-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
</div>
<div class="composer-ws-wrap">
<button class="composer-workspace-chip ws-chip" id="composerWorkspaceChip" type="button" onclick="toggleComposerWsDropdown()" title="Switch workspace" disabled>
<span class="composer-workspace-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
<span class="composer-workspace-label" id="composerWorkspaceLabel">Workspace</span>
<span class="composer-workspace-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
</div>
<div class="composer-model-wrap">
<button class="composer-model-chip" id="composerModelChip" type="button" onclick="toggleModelDropdown()" title="Conversation model">
<span class="composer-model-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg></span>
<span class="composer-model-label" id="composerModelLabel">Model</span>
<span class="composer-model-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<select id="modelSelect" class="composer-model-select" title="Conversation model" aria-hidden="true" tabindex="-1">
<optgroup label="OpenAI">
<option value="openai/gpt-5.4-mini">GPT-5.4 Mini</option>
<option value="openai/gpt-4o">GPT-4o</option>
<option value="openai/o3">o3</option>
<option value="openai/o4-mini">o4-mini</option>
</optgroup>
<optgroup label="Anthropic">
<option value="anthropic/claude-sonnet-4.6">Claude Sonnet 4.6</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="anthropic/claude-haiku-3-5">Claude Haiku 3.5</option>
</optgroup>
<optgroup label="Other">
<option value="google/gemini-2.5-pro">Gemini 2.5 Pro</option>
<option value="deepseek/deepseek-chat-v3-0324">DeepSeek V3</option>
<option value="meta-llama/llama-4-scout">Llama 4 Scout</option>
</optgroup>
</select>
</div>
</div> </div>
<div class="composer-right"> <div class="composer-right">
<button class="send-btn" id="btnSend" title="Send message" style="display:none"> <span class="composer-status" id="composerStatus" style="display:none"></span>
<div class="ctx-indicator-wrap" id="ctxIndicatorWrap" style="display:none">
<button class="ctx-indicator" id="ctxIndicator" type="button" aria-label="Context window usage" aria-describedby="ctxTooltip">
<span class="ctx-ring">
<svg class="ctx-ring-svg" viewBox="0 0 24 24" aria-hidden="true">
<circle class="ctx-ring-track" cx="12" cy="12" r="9.75"></circle>
<circle class="ctx-ring-value" id="ctxRingValue" cx="12" cy="12" r="9.75"></circle>
</svg>
<span class="ctx-ring-center" id="ctxPercent">0</span>
</span>
</button>
<div class="ctx-tooltip" id="ctxTooltip" role="tooltip" aria-hidden="true">
<div class="ctx-tooltip-title">Context window</div>
<div class="ctx-tooltip-line" id="ctxTooltipUsage"></div>
<div class="ctx-tooltip-line" id="ctxTooltipTokens"></div>
<div class="ctx-tooltip-line" id="ctxTooltipThreshold"></div>
<div class="ctx-tooltip-line" id="ctxTooltipCost" style="display:none"></div>
</div>
</div>
<button class="cancel-btn" id="btnCancel" onclick="cancelStream()" style="display:none" title="Stop generation" aria-label="Stop generation">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2"></rect></svg>
</button>
<button class="send-btn" id="btnSend" title="Send message" disabled>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
</button> </button>
</div> </div>
<div class="profile-dropdown" id="profileDropdown"></div>
<div class="ws-dropdown ws-dropdown-footer" id="composerWsDropdown"></div>
<div class="model-dropdown" id="composerModelDropdown"></div>
</div> </div>
<div class="upload-bar-wrap" id="uploadBarWrap"><div class="upload-bar" id="uploadBar"></div></div> <div class="upload-bar-wrap" id="uploadBarWrap"><div class="upload-bar" id="uploadBar"></div></div>
</div> </div>
@@ -323,12 +350,13 @@
<span>Workspace</span> <span>Workspace</span>
<span class="git-badge" id="gitBadge" style="display:none"></span> <span class="git-badge" id="gitBadge" style="display:none"></span>
<div class="panel-actions"> <div class="panel-actions">
<button class="panel-icon-btn" id="btnCollapseWorkspacePanel" title="Hide workspace panel" onclick="toggleWorkspacePanel(false)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg></button>
<button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button> <button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button>
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button> <button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button> <button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button> <button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button> <button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<button class="panel-icon-btn mobile-close-btn" onclick="closeMobileFiles()" title="Close" aria-label="Close workspace panel">×</button> <button class="panel-icon-btn mobile-close-btn" onclick="closeWorkspacePanel()" title="Close" aria-label="Close workspace panel">×</button>
</div> </div>
</div> </div>
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div> <div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
@@ -370,96 +398,151 @@
<div class="settings-overlay" id="settingsOverlay" style="display:none"> <div class="settings-overlay" id="settingsOverlay" style="display:none">
<div class="settings-panel"> <div class="settings-panel">
<div class="settings-header"> <div class="settings-header">
<h3 style="margin:0;font-size:16px" data-i18n="settings_title">Settings</h3> <div class="settings-heading">
<div class="settings-kicker">Hermes WebUI</div>
<h3 style="margin:0;font-size:18px">Control Center</h3>
<div class="settings-subtitle">Preferences, conversation tools, and system controls.</div>
</div>
<button class="panel-icon-btn" onclick="_closeSettingsPanel()" title="Close"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button> <button class="panel-icon-btn" onclick="_closeSettingsPanel()" title="Close"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div> </div>
<div class="settings-body"> <div class="settings-body">
<div class="settings-field"> <div class="settings-shell">
<label for="settingsModel" data-i18n="settings_label_model">Default Model</label> <div class="settings-tabs" role="tablist" aria-label="Hermes control center sections">
<select id="settingsModel" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select> <button class="settings-tab active" id="settingsTabConversation" type="button" role="tab" aria-selected="true" aria-controls="settingsPaneConversation" onclick="switchSettingsSection('conversation')">
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span class="settings-tab-title">Conversation</span>
</button>
<button class="settings-tab" id="settingsTabPreferences" type="button" role="tab" aria-selected="false" aria-controls="settingsPanePreferences" onclick="switchSettingsSection('preferences')">
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
<span class="settings-tab-title">Preferences</span>
</button>
<button class="settings-tab" id="settingsTabSystem" type="button" role="tab" aria-selected="false" aria-controls="settingsPaneSystem" onclick="switchSettingsSection('system')">
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>
<span class="settings-tab-title">System</span>
</button>
</div>
<div class="settings-main">
<div class="settings-pane active" id="settingsPaneConversation" role="tabpanel" aria-labelledby="settingsTabConversation">
<div class="settings-section-head">
<div>
<div class="settings-section-title">Conversation</div>
<div class="settings-section-meta" id="hermesSessionMeta">No active conversation selected.</div>
</div>
</div>
<div class="hermes-action-grid">
<button class="settings-action-btn" id="btnDownload" title="Download as Markdown" data-i18n-title="download_transcript"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <span data-i18n="transcript">Transcript</span></button>
<button class="settings-action-btn" id="btnExportJSON" title="Export full session as JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg> JSON</button>
<button class="settings-action-btn" id="btnImportJSON" title="Import session from JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> <span data-i18n="import">Import</span></button>
<button class="settings-action-btn danger" id="btnClearConvModal" onclick="clearConversation()" title="Clear all messages in this conversation"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 1 2 2 2v2"/></svg> Clear</button>
</div>
<input type="file" id="importFileInput" accept=".json" style="display:none">
</div>
<div class="settings-pane" id="settingsPanePreferences" role="tabpanel" aria-labelledby="settingsTabPreferences">
<div class="settings-section-head">
<div>
<div class="settings-section-title">Preferences</div>
<div class="settings-section-meta">Defaults and UI behavior for Hermes Web UI.</div>
</div>
</div>
<div class="settings-field">
<label for="settingsModel" data-i18n="settings_label_model">Default Model</label>
<select id="settingsModel" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
</div>
<div class="settings-field">
<label for="settingsSendKey" data-i18n="settings_label_send_key">Send Key</label>
<select id="settingsSendKey" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
<option value="enter">Enter (Shift+Enter for newline)</option>
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
</select>
</div>
<div class="settings-field">
<label for="settingsTheme" data-i18n="settings_label_theme">Theme</label>
<select id="settingsTheme" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px" onchange="document.documentElement.dataset.theme=this.value;localStorage.setItem('hermes-theme',this.value)">
<option value="dark">Dark (default)</option>
<option value="light">Light</option>
<option value="slate">Slate (charcoal)</option>
<option value="solarized">Solarized Dark</option>
<option value="monokai">Monokai</option>
<option value="nord">Nord</option>
<option value="oled">OLED</option>
</select>
</div>
<div class="settings-field">
<label for="settingsLanguage" data-i18n="settings_label_language">Language</label>
<select id="settingsLanguage" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsSoundEnabled" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_sound">Notification sound</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sound">Play a sound when the assistant finishes a response.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsNotificationsEnabled" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_notifications">Browser notifications</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_notifications">Show a system notification when a response completes while the tab is in the background.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowTokenUsage" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_token_usage">Show token usage after responses</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_token_usage">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowCliSessions" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_cli_sessions">Show CLI sessions in sidebar</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_cli_sessions">Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsSyncInsights" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_sync_insights">Sync usage to /insights</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sync_insights">Mirrors WebUI token usage to state.db so <code>hermes /insights</code> includes browser session data. Off by default.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsCheckUpdates" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_check_updates">Check for updates</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_check_updates">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
</div>
<div class="settings-field">
<label for="settingsBotName" data-i18n="settings_label_bot_name">Assistant Name</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_bot_name">Display name for the assistant throughout the UI. Defaults to Hermes.</div>
<input type="text" id="settingsBotName" placeholder="Hermes" maxlength="64" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
</div>
<div class="settings-pane" id="settingsPaneSystem" role="tabpanel" aria-labelledby="settingsTabSystem">
<div class="settings-section-head">
<div>
<div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div>
</div>
<span class="settings-version-badge">v0.50.0</span>
</div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_password">Enter a new password to set or change it. Leave blank to keep current setting.</div>
<input type="password" id="settingsPassword" placeholder="Enter new password…" data-i18n-placeholder="password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none" data-i18n="disable_auth">Disable Auth</button>
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
</div>
</div>
</div> </div>
<div class="settings-field">
<label for="settingsSendKey" data-i18n="settings_label_send_key">Send Key</label>
<select id="settingsSendKey" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
<option value="enter">Enter (Shift+Enter for newline)</option>
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
</select>
</div> </div>
<div class="settings-field">
<label for="settingsTheme" data-i18n="settings_label_theme">Theme</label>
<select id="settingsTheme" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px" onchange="document.documentElement.dataset.theme=this.value;localStorage.setItem('hermes-theme',this.value)">
<option value="dark">Dark (default)</option>
<option value="light">Light</option>
<option value="slate">Slate (charcoal)</option>
<option value="solarized">Solarized Dark</option>
<option value="monokai">Monokai</option>
<option value="nord">Nord</option>
<option value="oled">OLED</option>
</select>
</div>
<div class="settings-field">
<label for="settingsLanguage" data-i18n="settings_label_language">Language</label>
<select id="settingsLanguage" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsSoundEnabled" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_sound">Notification sound</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sound">Play a sound when the assistant finishes a response.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsNotificationsEnabled" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_notifications">Browser notifications</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_notifications">Show a system notification when a response completes while the tab is in the background.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowTokenUsage" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_token_usage">Show token usage after responses</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_token_usage">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowCliSessions" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_cli_sessions">Show agent sessions in sidebar</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_cli_sessions">Merges sessions from Hermes agent platforms (CLI, Telegram, Discord, Slack, etc.) into the session list. Agent sessions are view-only.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsSyncInsights" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_sync_insights">Sync usage to /insights</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sync_insights">Mirrors WebUI token usage to state.db so <code>hermes /insights</code> includes browser session data. Off by default.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsCheckUpdates" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_check_updates">Check for updates</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_check_updates">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
</div>
<div class="settings-field">
<label for="settingsBotName" data-i18n="settings_label_bot_name">Assistant Name</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_bot_name">Display name for the assistant throughout the UI. Defaults to Hermes.</div>
<input type="text" id="settingsBotName" placeholder="Hermes" maxlength="64" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_password">Enter a new password to set or change it. Leave blank to keep current setting.</div>
<input type="password" id="settingsPassword" placeholder="Enter new password…" data-i18n-placeholder="password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none" data-i18n="disable_auth">Disable Auth</button>
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
</div> </div>
</div> </div>
</div> </div>
<div class="mobile-overlay" id="mobileOverlay" onclick="closeMobileSidebar();closeMobileFiles()"></div> <div class="mobile-overlay" id="mobileOverlay" onclick="closeMobileSidebar()"></div>
<nav class="mobile-bottom-nav" id="mobileBottomNav"> <nav class="mobile-bottom-nav" id="mobileBottomNav">
<button class="mobile-nav-btn active" data-panel="chat" onclick="mobileSwitchPanel('chat')"> <button class="mobile-nav-btn active" data-panel="chat" onclick="mobileSwitchPanel('chat')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
@@ -486,17 +569,6 @@
<span data-i18n="tab_profiles">Profiles</span> <span data-i18n="tab_profiles">Profiles</span>
</button> </button>
</nav> </nav>
<div class="toast" id="toast"></div>
<script src="/static/i18n.js"></script>
<script src="/static/icons.js"></script>
<script src="/static/ui.js"></script>
<script src="/static/workspace.js"></script>
<script src="/static/sessions.js"></script>
<script src="/static/commands.js"></script>
<script src="/static/messages.js"></script>
<script src="/static/panels.js"></script>
<script src="/static/onboarding.js"></script>
<script src="/static/boot.js"></script>
<div class="app-dialog-overlay" id="appDialogOverlay" style="display:none" aria-hidden="true"> <div class="app-dialog-overlay" id="appDialogOverlay" style="display:none" aria-hidden="true">
<div class="app-dialog" id="appDialog" role="dialog" aria-modal="true" aria-labelledby="appDialogTitle" aria-describedby="appDialogDesc"> <div class="app-dialog" id="appDialog" role="dialog" aria-modal="true" aria-labelledby="appDialogTitle" aria-describedby="appDialogDesc">
<div class="app-dialog-header"> <div class="app-dialog-header">
@@ -513,5 +585,16 @@
</div> </div>
</div> </div>
</div> </div>
<div class="toast" id="toast"></div>
<script src="/static/i18n.js"></script>
<script src="/static/icons.js"></script>
<script src="/static/ui.js"></script>
<script src="/static/workspace.js"></script>
<script src="/static/sessions.js"></script>
<script src="/static/commands.js"></script>
<script src="/static/messages.js"></script>
<script src="/static/panels.js"></script>
<script src="/static/onboarding.js"></script>
<script src="/static/boot.js"></script>
</body> </body>
</html> </html>

View File

@@ -21,22 +21,22 @@ async function send(){
const activeSid=S.session.session_id; const activeSid=S.session.session_id;
setStatus(S.pendingFiles&&S.pendingFiles.length?'Uploading…':'Sending…'); setComposerStatus(S.pendingFiles&&S.pendingFiles.length?'Uploading…':'');
let uploaded=[]; let uploaded=[];
try{uploaded=await uploadPendingFiles();} try{uploaded=await uploadPendingFiles();}
catch(e){if(!text){setStatus(`Upload error: ${e.message}`);return;}} catch(e){if(!text){setComposerStatus(`Upload error: ${e.message}`);return;}}
let msgText=text; let msgText=text;
if(uploaded.length&&!msgText)msgText=`I've uploaded ${uploaded.length} file(s): ${uploaded.join(', ')}`; if(uploaded.length&&!msgText)msgText=`I've uploaded ${uploaded.length} file(s): ${uploaded.join(', ')}`;
else if(uploaded.length)msgText=`${text}\n\n[Attached files: ${uploaded.join(', ')}]`; else if(uploaded.length)msgText=`${text}\n\n[Attached files: ${uploaded.join(', ')}]`;
if(!msgText){setStatus('Nothing to send');return;} if(!msgText){setComposerStatus('Nothing to send');return;}
$('msg').value='';autoResize(); $('msg').value='';autoResize();
const displayText=text||(uploaded.length?`Uploaded: ${uploaded.join(', ')}`:'(file upload)'); const displayText=text||(uploaded.length?`Uploaded: ${uploaded.join(', ')}`:'(file upload)');
const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploaded:undefined,_ts:Date.now()/1000}; const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploaded:undefined,_ts:Date.now()/1000};
S.toolCalls=[]; // clear tool calls from previous turn S.toolCalls=[]; // clear tool calls from previous turn
clearLiveToolCards(); // clear any leftover live cards from last turn clearLiveToolCards(); // clear any leftover live cards from last turn
S.messages.push(userMsg);renderMessages();appendThinking();setBusy(true); // activity bar shown via setBusy S.messages.push(userMsg);renderMessages();appendThinking();setBusy(true);
INFLIGHT[activeSid]={messages:[...S.messages],uploaded}; INFLIGHT[activeSid]={messages:[...S.messages],uploaded};
startApprovalPolling(activeSid); startApprovalPolling(activeSid);
S.activeStreamId = null; // will be set after stream starts S.activeStreamId = null; // will be set after stream starts
@@ -76,7 +76,7 @@ async function send(){
// Only hide approval card if it belongs to the session that just finished // Only hide approval card if it belongs to the session that just finished
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);removeThinking(); if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);removeThinking();
S.messages.push({role:'assistant',content:`**Error:** ${e.message}`}); S.messages.push({role:'assistant',content:`**Error:** ${e.message}`});
renderMessages();setBusy(false);setStatus('Error: '+e.message); renderMessages();setBusy(false);setComposerStatus(`Error: ${e.message}`);
return; return;
} }
@@ -156,9 +156,6 @@ async function send(){
source.addEventListener('tool',e=>{ source.addEventListener('tool',e=>{
const d=JSON.parse(e.data); const d=JSON.parse(e.data);
if(S.session&&S.session.session_id===activeSid){
setStatus(`${d.name}${d.preview?' · '+d.preview.slice(0,55):''}`);
}
if(!S.session||S.session.session_id!==activeSid) return; if(!S.session||S.session.session_id!==activeSid) return;
removeThinking(); removeThinking();
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove(); const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
@@ -207,6 +204,7 @@ async function send(){
syncTopbar();renderMessages();loadDir('.'); syncTopbar();renderMessages();loadDir('.');
} }
renderSessionList();setBusy(false);setStatus(''); renderSessionList();setBusy(false);setStatus('');
setComposerStatus('');
playNotificationSound(); playNotificationSound();
sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished'); sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished');
}); });
@@ -247,7 +245,7 @@ async function send(){
try{const d=JSON.parse(e.data);trackBackgroundError(activeSid,_errTitle,d.message||'Error');} try{const d=JSON.parse(e.data);trackBackgroundError(activeSid,_errTitle,d.message||'Error');}
catch(_){trackBackgroundError(activeSid,_errTitle,'Error');} catch(_){trackBackgroundError(activeSid,_errTitle,'Error');}
} }
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setStatus('');} if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setComposerStatus('');}
}); });
source.addEventListener('warning',e=>{ source.addEventListener('warning',e=>{
@@ -256,9 +254,9 @@ async function send(){
try{ try{
const d=JSON.parse(e.data); const d=JSON.parse(e.data);
// Show as a small inline notice, not a full error // Show as a small inline notice, not a full error
setStatus(`${d.message||'Warning'}`); setComposerStatus(`${d.message||'Warning'}`);
// If it's a fallback notice, show it briefly then clear // If it's a fallback notice, show it briefly then clear
if(d.type==='fallback') setTimeout(()=>setStatus(''),4000); if(d.type==='fallback') setTimeout(()=>setComposerStatus(''),4000);
}catch(_){} }catch(_){}
}); });
@@ -267,12 +265,12 @@ async function send(){
// Attempt one reconnect if the stream is still active server-side // Attempt one reconnect if the stream is still active server-side
if(!_reconnectAttempted && streamId){ if(!_reconnectAttempted && streamId){
_reconnectAttempted=true; _reconnectAttempted=true;
setStatus('Connection lost \u2014 reconnecting\u2026'); setComposerStatus('Reconnecting');
setTimeout(async()=>{ setTimeout(async()=>{
try{ try{
const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`); const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
if(st.active){ if(st.active){
setStatus('Reconnected'); setComposerStatus('Reconnected');
_wireSSE(new EventSource(new URL(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{withCredentials:true})); _wireSSE(new EventSource(new URL(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{withCredentials:true}));
return; return;
} }
@@ -296,8 +294,7 @@ async function send(){
S.messages.push({role:'assistant',content:'*Task cancelled.*'});renderMessages(); S.messages.push({role:'assistant',content:'*Task cancelled.*'});renderMessages();
} }
renderSessionList(); renderSessionList();
// Always clear busy state and status when cancel event is received if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setComposerStatus('');}
setBusy(false);setStatus('');
}); });
} }
@@ -316,7 +313,7 @@ async function send(){
trackBackgroundError(activeSid,_errTitle,'Connection lost'); trackBackgroundError(activeSid,_errTitle,'Connection lost');
} }
} }
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setStatus('Error: Connection lost');} if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setComposerStatus('');}
} }
_wireSSE(new EventSource(new URL(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{withCredentials:true})); _wireSSE(new EventSource(new URL(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{withCredentials:true}));

View File

@@ -42,15 +42,15 @@ async function loadCrons() {
<span class="cron-status ${statusClass}">${statusLabel}</span> <span class="cron-status ${statusClass}">${statusLabel}</span>
</div> </div>
<div class="cron-body" id="cron-body-${job.id}"> <div class="cron-body" id="cron-body-${job.id}">
<div class="cron-schedule">&#128337; ${esc(job.schedule_display || job.schedule?.expression || '')} &nbsp;|&nbsp; Next: ${esc(nextRun)} &nbsp;|&nbsp; Last: ${esc(lastRun)}</div> <div class="cron-schedule">${li('clock',12)} ${esc(job.schedule_display || job.schedule?.expression || '')} &nbsp;|&nbsp; Next: ${esc(nextRun)} &nbsp;|&nbsp; Last: ${esc(lastRun)}</div>
<div class="cron-prompt">${esc((job.prompt||'').slice(0,300))}${(job.prompt||'').length>300?'…':''}</div> <div class="cron-prompt">${esc((job.prompt||'').slice(0,300))}${(job.prompt||'').length>300?'…':''}</div>
<div class="cron-actions"> <div class="cron-actions">
<button class="cron-btn run" onclick="cronRun('${job.id}')">&#9654; Run now</button> <button class="cron-btn run" onclick="cronRun('${job.id}')">${li('play',12)} Run now</button>
${statusLabel==='paused' ${statusLabel==='paused'
? `<button class="cron-btn" onclick="cronResume('${job.id}')">&#9654;&#9474; Resume</button>` ? `<button class="cron-btn" onclick="cronResume('${job.id}')">${li('play',12)} Resume</button>`
: `<button class="cron-btn pause" onclick="cronPause('${job.id}')">&#9646;&#9646; Pause</button>`} : `<button class="cron-btn pause" onclick="cronPause('${job.id}')">${li('pause',12)} Pause</button>`}
<button class="cron-btn" onclick="cronEditOpen('${job.id}',${JSON.stringify(job).replace(/"/g,'&quot;')})">&#9998; Edit</button> <button class="cron-btn" onclick="cronEditOpen('${job.id}',${JSON.stringify(job).replace(/"/g,'&quot;')})">${li('pencil',12)} Edit</button>
<button class="cron-btn" style="border-color:rgba(201,168,76,.3);color:var(--accent)" onclick="cronDelete('${job.id}')">&#128465; Delete</button> <button class="cron-btn" style="border-color:rgba(201,168,76,.3);color:var(--accent)" onclick="cronDelete('${job.id}')">${li('trash-2',12)} Delete</button>
</div> </div>
<!-- Inline edit form, hidden by default --> <!-- Inline edit form, hidden by default -->
<div id="cron-edit-${job.id}" style="display:none;margin-top:8px;border-top:1px solid var(--border);padding-top:8px"> <div id="cron-edit-${job.id}" style="display:none;margin-top:8px;border-top:1px solid var(--border);padding-top:8px">
@@ -172,7 +172,7 @@ async function submitCronCreate(){
if(_cronSelectedSkills.length)body.skills=_cronSelectedSkills; if(_cronSelectedSkills.length)body.skills=_cronSelectedSkills;
await api('/api/crons/create',{method:'POST',body:JSON.stringify(body)}); await api('/api/crons/create',{method:'POST',body:JSON.stringify(body)});
toggleCronForm(); toggleCronForm();
showToast('Job created'); showToast('Job created');
await loadCrons(); await loadCrons();
}catch(e){ }catch(e){
errEl.textContent='Error: '+e.message;errEl.style.display=''; errEl.textContent='Error: '+e.message;errEl.style.display='';
@@ -242,7 +242,7 @@ function toggleCron(id) {
async function cronRun(id) { async function cronRun(id) {
try { try {
await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id})}); await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id})});
showToast('Job triggered'); showToast('Job triggered');
setTimeout(() => loadCronOutput(id), 5000); setTimeout(() => loadCronOutput(id), 5000);
} catch(e) { showToast('Run failed: ' + e.message, 4000); } } catch(e) { showToast('Run failed: ' + e.message, 4000); }
} }
@@ -258,7 +258,7 @@ async function cronPause(id) {
async function cronResume(id) { async function cronResume(id) {
try { try {
await api('/api/crons/resume', {method:'POST', body: JSON.stringify({job_id: id})}); await api('/api/crons/resume', {method:'POST', body: JSON.stringify({job_id: id})});
showToast('Job resumed'); showToast('Job resumed');
await loadCrons(); await loadCrons();
} catch(e) { showToast('Resume failed: ' + e.message, 4000); } } catch(e) { showToast('Resume failed: ' + e.message, 4000); }
} }
@@ -290,7 +290,7 @@ async function cronEditSave(id) {
const updates = {job_id: id, schedule, prompt}; const updates = {job_id: id, schedule, prompt};
if (name) updates.name = name; if (name) updates.name = name;
await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)}); await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)});
showToast('Job updated'); showToast('Job updated');
await loadCrons(); await loadCrons();
} catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; } } catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; }
} }
@@ -326,11 +326,11 @@ function loadTodos() {
panel.innerHTML = '<div style="color:var(--muted);font-size:12px;padding:4px 0">No active task list in this session.</div>'; panel.innerHTML = '<div style="color:var(--muted);font-size:12px;padding:4px 0">No active task list in this session.</div>';
return; return;
} }
const statusIcon = {pending:'○', in_progress:'◉', completed:'✓', cancelled:'✗'}; const statusIcon = {pending:li('square',14), in_progress:li('loader',14), completed:li('check',14), cancelled:li('x',14)};
const statusColor = {pending:'var(--muted)', in_progress:'var(--blue)', completed:'rgba(100,200,100,.8)', cancelled:'rgba(200,100,100,.5)'}; const statusColor = {pending:'var(--muted)', in_progress:'var(--blue)', completed:'rgba(100,200,100,.8)', cancelled:'rgba(200,100,100,.5)'};
panel.innerHTML = todos.map(t => ` panel.innerHTML = todos.map(t => `
<div style="display:flex;align-items:flex-start;gap:10px;padding:6px 0;border-bottom:1px solid var(--border);"> <div style="display:flex;align-items:flex-start;gap:10px;padding:6px 0;border-bottom:1px solid var(--border);">
<span style="font-size:14px;flex-shrink:0;margin-top:1px;color:${statusColor[t.status]||'var(--muted)'}">${statusIcon[t.status]||'○'}</span> <span style="font-size:14px;display:inline-flex;align-items:center;flex-shrink:0;margin-top:1px;color:${statusColor[t.status]||'var(--muted)'}">${statusIcon[t.status]||li('square',14)}</span>
<div style="flex:1;min-width:0"> <div style="flex:1;min-width:0">
<div style="font-size:13px;color:${t.status==='completed'?'var(--muted)':t.status==='in_progress'?'var(--text)':'var(--text)'};${t.status==='completed'?'text-decoration:line-through;opacity:.5':''};line-height:1.4">${esc(t.content)}</div> <div style="font-size:13px;color:${t.status==='completed'?'var(--muted)':t.status==='in_progress'?'var(--text)':'var(--text)'};${t.status==='completed'?'text-decoration:line-through;opacity:.5':''};line-height:1.4">${esc(t.content)}</div>
<div style="font-size:10px;color:var(--muted);margin-top:2px;opacity:.6">${esc(t.id)} · ${esc(t.status)}</div> <div style="font-size:10px;color:var(--muted);margin-top:2px;opacity:.6">${esc(t.id)} · ${esc(t.status)}</div>
@@ -385,7 +385,7 @@ function renderSkills(skills) {
for (const [cat, items] of Object.entries(cats).sort()) { for (const [cat, items] of Object.entries(cats).sort()) {
const sec = document.createElement('div'); const sec = document.createElement('div');
sec.className = 'skills-category'; sec.className = 'skills-category';
sec.innerHTML = `<div class="skills-cat-header">&#128193; ${esc(cat)} <span style="opacity:.5">(${items.length})</span></div>`; sec.innerHTML = `<div class="skills-cat-header">${li('folder',12)} ${esc(cat)} <span style="opacity:.5">(${items.length})</span></div>`;
for (const skill of items.sort((a,b) => a.name.localeCompare(b.name))) { for (const skill of items.sort((a,b) => a.name.localeCompare(b.name))) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'skill-item'; el.className = 'skill-item';
@@ -482,7 +482,7 @@ async function submitSkillSave() {
if (!content.trim()) { errEl.textContent = 'Content is required'; errEl.style.display = ''; return; } if (!content.trim()) { errEl.textContent = 'Content is required'; errEl.style.display = ''; return; }
try { try {
await api('/api/skills/save', {method:'POST', body: JSON.stringify({name, category: category||undefined, content})}); await api('/api/skills/save', {method:'POST', body: JSON.stringify({name, category: category||undefined, content})});
showToast(_editingSkillName ? 'Skill updated' : 'Skill created'); showToast(_editingSkillName ? 'Skill updated' : 'Skill created');
_skillsData = null; _skillsData = null;
toggleSkillForm(); toggleSkillForm();
await loadSkills(); await loadSkills();
@@ -514,7 +514,7 @@ async function submitMemorySave() {
errEl.style.display = 'none'; errEl.style.display = 'none';
try { try {
await api('/api/memory/write', {method:'POST', body: JSON.stringify({section: 'memory', content})}); await api('/api/memory/write', {method:'POST', body: JSON.stringify({section: 'memory', content})});
showToast('Memory saved'); showToast('Memory saved');
closeMemoryEdit(); closeMemoryEdit();
await loadMemory(true); await loadMemory(true);
} catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; } } catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; }
@@ -532,48 +532,95 @@ function getWorkspaceFriendlyName(path){
return path.split('/').filter(Boolean).pop()||path; return path.split('/').filter(Boolean).pop()||path;
} }
function syncWorkspaceDisplays(){
const hasSession=!!(S.session&&S.session.workspace);
const ws=hasSession?S.session.workspace:'';
const label=hasSession?getWorkspaceFriendlyName(ws):t('no_workspace');
const sidebarName=$('sidebarWsName');
const sidebarPath=$('sidebarWsPath');
if(sidebarName) sidebarName.textContent=label;
if(sidebarPath) sidebarPath.textContent=ws;
const composerChip=$('composerWorkspaceChip');
const composerLabel=$('composerWorkspaceLabel');
const composerDropdown=$('composerWsDropdown');
if(!hasSession && composerDropdown) composerDropdown.classList.remove('open');
if(composerLabel) composerLabel.textContent=label;
if(composerChip){
composerChip.disabled=!hasSession;
composerChip.title=hasSession?ws:'No active workspace';
composerChip.classList.toggle('active',!!(composerDropdown&&composerDropdown.classList.contains('open')));
}
}
async function loadWorkspaceList(){ async function loadWorkspaceList(){
try{ try{
const data = await api('/api/workspaces'); const data = await api('/api/workspaces');
_workspaceList = data.workspaces || []; _workspaceList = data.workspaces || [];
// Refresh sidebar display if we have a current session syncWorkspaceDisplays();
if(S.session && S.session.workspace) {
const sidebarName=$('sidebarWsName');
const sidebarPath=$('sidebarWsPath');
if(sidebarName) sidebarName.textContent=getWorkspaceFriendlyName(S.session.workspace);
if(sidebarPath) sidebarPath.textContent=S.session.workspace;
}
return data; return data;
}catch(e){ return {workspaces:[], last:''}; } }catch(e){ return {workspaces:[], last:''}; }
} }
function renderWorkspaceDropdown(workspaces, currentWs){ function _renderWorkspaceAction(label, meta, iconSvg, onClick){
const dd = $('wsDropdown'); const opt=document.createElement('div');
opt.className='ws-opt ws-opt-action';
opt.innerHTML=`<span class="ws-opt-icon">${iconSvg}</span><span><span class="ws-opt-name">${esc(label)}</span>${meta?`<span class="ws-opt-meta">${esc(meta)}</span>`:''}</span>`;
opt.onclick=onClick;
return opt;
}
function _positionComposerWsDropdown(){
const dd=$('composerWsDropdown');
const chip=$('composerWorkspaceChip');
const footer=document.querySelector('.composer-footer');
if(!dd||!chip||!footer)return;
const chipRect=chip.getBoundingClientRect();
const footerRect=footer.getBoundingClientRect();
let left=chipRect.left-footerRect.left;
const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth);
left=Math.max(0, Math.min(left, maxLeft));
dd.style.left=`${left}px`;
}
function _positionProfileDropdown(){
const dd=$('profileDropdown');
const chip=$('profileChip');
const footer=document.querySelector('.composer-footer');
if(!dd||!chip||!footer)return;
const chipRect=chip.getBoundingClientRect();
const footerRect=footer.getBoundingClientRect();
let left=chipRect.left-footerRect.left;
const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth);
left=Math.max(0, Math.min(left, maxLeft));
dd.style.left=`${left}px`;
}
function renderWorkspaceDropdownInto(dd, workspaces, currentWs){
if(!dd)return; if(!dd)return;
dd.innerHTML=''; dd.innerHTML='';
for(const w of workspaces){ for(const w of workspaces){
const opt=document.createElement('div'); const opt=document.createElement('div');
opt.className='ws-opt'+(w.path===currentWs?' active':''); opt.className='ws-opt'+(w.path===currentWs?' active':'');
opt.innerHTML=`<span class="ws-opt-name">${esc(w.name)}</span><span class="ws-opt-path">${esc(w.path)}</span>`; opt.innerHTML=`<span class="ws-opt-name">${esc(w.name)}</span><span class="ws-opt-path">${esc(w.path)}</span>`;
opt.onclick=async()=>{ opt.onclick=()=>switchToWorkspace(w.path,w.name);
closeWsDropdown();
if(!S.session||w.path===S.session.workspace)return;
await api('/api/session/update',{method:'POST',body:JSON.stringify({
session_id:S.session.session_id, workspace:w.path, model:S.session.model
})});
S.session.workspace=w.path;
syncTopbar();
await loadDir('.');
showToast(`Switched to ${w.name}`);
};
dd.appendChild(opt); dd.appendChild(opt);
} }
// Divider + Manage link dd.appendChild(document.createElement('div')).className='ws-divider';
dd.appendChild(_renderWorkspaceAction(
'Choose workspace path',
'Add a validated path and switch this conversation',
li('folder',12),
()=>promptWorkspacePath()
));
const div=document.createElement('div');div.className='ws-divider';dd.appendChild(div); const div=document.createElement('div');div.className='ws-divider';dd.appendChild(div);
const mgmt=document.createElement('div');mgmt.className='ws-opt ws-manage'; dd.appendChild(_renderWorkspaceAction(
mgmt.innerHTML='&#9881; Manage workspaces'; 'Manage workspaces',
mgmt.onclick=()=>{closeWsDropdown();switchPanel('workspaces');}; 'Open the Spaces panel',
dd.appendChild(mgmt); li('settings',12),
()=>{closeWsDropdown();mobileSwitchPanel('workspaces');}
));
} }
function toggleWsDropdown(){ function toggleWsDropdown(){
@@ -584,18 +631,47 @@ function toggleWsDropdown(){
else{ else{
closeProfileDropdown(); // close profile dropdown if open closeProfileDropdown(); // close profile dropdown if open
loadWorkspaceList().then(data=>{ loadWorkspaceList().then(data=>{
renderWorkspaceDropdown(data.workspaces, S.session?S.session.workspace:''); renderWorkspaceDropdownInto(dd, data.workspaces, S.session?S.session.workspace:'');
dd.classList.add('open'); dd.classList.add('open');
}); });
} }
} }
function toggleComposerWsDropdown(){
const dd=$('composerWsDropdown');
const chip=$('composerWorkspaceChip');
if(!dd||!chip||chip.disabled)return;
const open=dd.classList.contains('open');
if(open){closeWsDropdown();}
else{
closeProfileDropdown();
if(typeof closeModelDropdown==='function') closeModelDropdown();
loadWorkspaceList().then(data=>{
renderWorkspaceDropdownInto(dd, data.workspaces, S.session?S.session.workspace:'');
dd.classList.add('open');
_positionComposerWsDropdown();
chip.classList.add('active');
});
}
}
function closeWsDropdown(){ function closeWsDropdown(){
const dd=$('wsDropdown'); const dd=$('wsDropdown');
const composerDd=$('composerWsDropdown');
const composerChip=$('composerWorkspaceChip');
if(dd)dd.classList.remove('open'); if(dd)dd.classList.remove('open');
if(composerDd)composerDd.classList.remove('open');
if(composerChip)composerChip.classList.remove('active');
} }
document.addEventListener('click',e=>{ document.addEventListener('click',e=>{
if(!e.target.closest('#sidebarWsDisplay') && !e.target.closest('#wsDropdown'))closeWsDropdown(); if(
!e.target.closest('#composerWorkspaceChip') &&
!e.target.closest('#composerWsDropdown')
) closeWsDropdown();
});
window.addEventListener('resize',()=>{
const dd=$('composerWsDropdown');
if(dd&&dd.classList.contains('open')) _positionComposerWsDropdown();
}); });
async function loadWorkspacesPanel(){ async function loadWorkspacesPanel(){
@@ -616,15 +692,15 @@ function renderWorkspacesPanel(workspaces){
<div class="ws-row-path">${esc(w.path)}</div> <div class="ws-row-path">${esc(w.path)}</div>
</div> </div>
<div class="ws-row-actions"> <div class="ws-row-actions">
<button class="ws-action-btn" title="Use in current session" onclick="switchToWorkspace('${esc(w.path)}','${esc(w.name)}')">&#8594; Use</button> <button class="ws-action-btn" title="Use in current session" onclick="switchToWorkspace('${esc(w.path)}','${esc(w.name)}')">${li('arrow-right',12)} Use</button>
<button class="ws-action-btn danger" title="Remove" onclick="removeWorkspace('${esc(w.path)}')">&#10005;</button> <button class="ws-action-btn danger" title="Remove" onclick="removeWorkspace('${esc(w.path)}')">${li('x',12)}</button>
</div>`; </div>`;
panel.appendChild(row); panel.appendChild(row);
} }
const addRow=document.createElement('div');addRow.className='ws-add-row'; const addRow=document.createElement('div');addRow.className='ws-add-row';
addRow.innerHTML=` addRow.innerHTML=`
<input id="wsAddInput" placeholder="Add workspace path (e.g. /home/user/my-project)" style="flex:1;background:rgba(255,255,255,.06);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:7px 10px;font-size:12px;outline:none;"> <input id="wsAddInput" placeholder="Add workspace path (e.g. /home/user/my-project)" style="flex:1;background:rgba(255,255,255,.06);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:7px 10px;font-size:12px;outline:none;">
<button class="ws-action-btn" onclick="addWorkspace()">&#43; Add</button>`; <button class="ws-action-btn" onclick="addWorkspace()">${li('plus',12)} Add</button>`;
panel.appendChild(addRow); panel.appendChild(addRow);
const hint=document.createElement('div'); const hint=document.createElement('div');
hint.style.cssText='font-size:11px;color:var(--muted);padding:4px 0 8px'; hint.style.cssText='font-size:11px;color:var(--muted);padding:4px 0 8px';
@@ -656,16 +732,58 @@ async function removeWorkspace(path){
}catch(e){setStatus('Remove failed: '+e.message);} }catch(e){setStatus('Remove failed: '+e.message);}
} }
async function promptWorkspacePath(){
if(!S.session)return;
const value=await showPromptDialog({
title:'Switch workspace',
message:'Enter an absolute workspace path to add and switch this conversation to.',
confirmLabel:'Switch',
placeholder:'/Users/you/project',
value:S.session.workspace||''
});
const path=(value||'').trim();
if(!path)return;
try{
const data=await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path})});
_workspaceList=data.workspaces||[];
const target=_workspaceList[_workspaceList.length-1];
if(!target) throw new Error('Workspace was not added');
await switchToWorkspace(target.path,target.name);
}catch(e){
if(String(e.message||'').includes('Workspace already in list')){
showToast('Workspace already saved — choose it from the list');
return;
}
showToast('Workspace switch failed: '+e.message);
}
}
async function switchToWorkspace(path,name){ async function switchToWorkspace(path,name){
if(!S.session)return; if(!S.session)return;
if(S.busy){
showToast('Cannot switch workspace while agent is running');
return;
}
if(typeof _previewDirty!=='undefined'&&_previewDirty){
const discard=await showConfirmDialog({
title:'Discard file edits?',
message:'Switching workspaces will discard unsaved file edits in the preview.',
confirmLabel:t('discard'),
danger:true
});
if(!discard)return;
if(typeof cancelEditMode==='function')cancelEditMode();
if(typeof clearPreview==='function')clearPreview();
}
try{ try{
closeWsDropdown();
await api('/api/session/update',{method:'POST',body:JSON.stringify({ await api('/api/session/update',{method:'POST',body:JSON.stringify({
session_id:S.session.session_id, workspace:path, model:S.session.model session_id:S.session.session_id, workspace:path, model:S.session.model
})}); })});
S.session.workspace=path; S.session.workspace=path;
syncTopbar(); syncTopbar();
await loadDir('.'); await loadDir('.');
showToast(`Switched to ${name}`); showToast(`Switched to ${name||getWorkspaceFriendlyName(path)}`);
}catch(e){setStatus('Switch failed: '+e.message);} }catch(e){setStatus('Switch failed: '+e.message);}
} }
@@ -704,7 +822,7 @@ async function loadProfilesPanel() {
</div> </div>
<div class="profile-card-actions"> <div class="profile-card-actions">
${!isActive ? `<button class="ws-action-btn" onclick="switchToProfile('${esc(p.name)}')" title="Switch to this profile">Use</button>` : ''} ${!isActive ? `<button class="ws-action-btn" onclick="switchToProfile('${esc(p.name)}')" title="Switch to this profile">Use</button>` : ''}
${!p.is_default ? `<button class="ws-action-btn danger" onclick="deleteProfile('${esc(p.name)}')" title="Delete this profile">&#10005;</button>` : ''} ${!p.is_default ? `<button class="ws-action-btn danger" onclick="deleteProfile('${esc(p.name)}')" title="Delete this profile">${li('x',12)}</button>` : ''}
</div> </div>
</div>`; </div>`;
panel.appendChild(card); panel.appendChild(card);
@@ -740,8 +858,8 @@ function renderProfileDropdown(data) {
// Divider + Manage link // Divider + Manage link
const div = document.createElement('div'); div.className = 'ws-divider'; dd.appendChild(div); const div = document.createElement('div'); div.className = 'ws-divider'; dd.appendChild(div);
const mgmt = document.createElement('div'); mgmt.className = 'profile-opt ws-manage'; const mgmt = document.createElement('div'); mgmt.className = 'profile-opt ws-manage';
mgmt.innerHTML = '&#9881; Manage profiles'; mgmt.innerHTML = `${li('settings',12)} Manage profiles`;
mgmt.onclick = () => { closeProfileDropdown(); switchPanel('profiles'); }; mgmt.onclick = () => { closeProfileDropdown(); mobileSwitchPanel('profiles'); };
dd.appendChild(mgmt); dd.appendChild(mgmt);
} }
@@ -750,18 +868,28 @@ function toggleProfileDropdown() {
if (!dd) return; if (!dd) return;
if (dd.classList.contains('open')) { closeProfileDropdown(); return; } if (dd.classList.contains('open')) { closeProfileDropdown(); return; }
closeWsDropdown(); // close workspace dropdown if open closeWsDropdown(); // close workspace dropdown if open
if(typeof closeModelDropdown==='function') closeModelDropdown();
api('/api/profiles').then(data => { api('/api/profiles').then(data => {
renderProfileDropdown(data); renderProfileDropdown(data);
dd.classList.add('open'); dd.classList.add('open');
_positionProfileDropdown();
const chip=$('profileChip');
if(chip) chip.classList.add('active');
}).catch(e => { showToast('Failed to load profiles'); }); }).catch(e => { showToast('Failed to load profiles'); });
} }
function closeProfileDropdown() { function closeProfileDropdown() {
const dd = $('profileDropdown'); const dd = $('profileDropdown');
if (dd) dd.classList.remove('open'); if (dd) dd.classList.remove('open');
const chip=$('profileChip');
if(chip) chip.classList.remove('active');
} }
document.addEventListener('click', e => { document.addEventListener('click', e => {
if (!e.target.closest('#profileChipWrap')) closeProfileDropdown(); if (!e.target.closest('#profileChipWrap') && !e.target.closest('#profileDropdown')) closeProfileDropdown();
});
window.addEventListener('resize',()=>{
const dd=$('profileDropdown');
if(dd&&dd.classList.contains('open')) _positionProfileDropdown();
}); });
async function switchToProfile(name) { async function switchToProfile(name) {
@@ -894,7 +1022,7 @@ async function loadMemory(force) {
panel.innerHTML = ` panel.innerHTML = `
<div class="memory-section"> <div class="memory-section">
<div class="memory-section-title"> <div class="memory-section-title">
&#129504; My Notes <span style="display:inline-flex;align-items:center;gap:6px">${li('brain',14)} My Notes</span>
<span class="memory-mtime">${fmtTime(data.memory_mtime)}</span> <span class="memory-mtime">${fmtTime(data.memory_mtime)}</span>
</div> </div>
${data.memory ${data.memory
@@ -903,7 +1031,7 @@ async function loadMemory(force) {
</div> </div>
<div class="memory-section"> <div class="memory-section">
<div class="memory-section-title"> <div class="memory-section-title">
&#128100; User Profile <span style="display:inline-flex;align-items:center;gap:6px">${li('user',14)} User Profile</span>
<span class="memory-mtime">${fmtTime(data.user_mtime)}</span> <span class="memory-mtime">${fmtTime(data.user_mtime)}</span>
</div> </div>
${data.user ${data.user
@@ -924,6 +1052,44 @@ document.addEventListener('drop',e=>{e.preventDefault();dragCounter=0;wrap.class
let _settingsDirty = false; let _settingsDirty = false;
let _settingsThemeOnOpen = null; // track theme at open time for discard revert let _settingsThemeOnOpen = null; // track theme at open time for discard revert
let _settingsSection = 'conversation';
function switchSettingsSection(name){
const section=(name==='preferences'||name==='system')?name:'conversation';
_settingsSection=section;
const map={conversation:'Conversation',preferences:'Preferences',system:'System'};
['conversation','preferences','system'].forEach(key=>{
const tab=$('settingsTab'+map[key]);
const pane=$('settingsPane'+map[key]);
const active=key===section;
if(tab){
tab.classList.toggle('active',active);
tab.setAttribute('aria-selected',active?'true':'false');
}
if(pane) pane.classList.toggle('active',active);
});
}
function _syncHermesPanelSessionActions(){
const hasSession=!!S.session;
const visibleMessages=hasSession?(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length:0;
const title=hasSession?(S.session.title||'Untitled'):'No active conversation selected.';
const meta=$('hermesSessionMeta');
if(meta){
meta.textContent=hasSession
? `${title} · ${visibleMessages} message${visibleMessages===1?'':'s'}`
: 'No active conversation selected.';
}
const setDisabled=(id,disabled)=>{
const el=$(id);
if(!el)return;
el.disabled=!!disabled;
el.classList.toggle('disabled',!!disabled);
};
setDisabled('btnDownload',!hasSession||visibleMessages===0);
setDisabled('btnExportJSON',!hasSession);
setDisabled('btnClearConvModal',!hasSession||visibleMessages===0);
}
function toggleSettings(){ function toggleSettings(){
const overlay=$('settingsOverlay'); const overlay=$('settingsOverlay');
@@ -931,6 +1097,7 @@ function toggleSettings(){
if(overlay.style.display==='none'){ if(overlay.style.display==='none'){
_settingsDirty = false; _settingsDirty = false;
_settingsThemeOnOpen = document.documentElement.dataset.theme || 'dark'; _settingsThemeOnOpen = document.documentElement.dataset.theme || 'dark';
_settingsSection = 'conversation';
overlay.style.display=''; overlay.style.display='';
loadSettingsPanel(); loadSettingsPanel();
} else { } else {
@@ -938,12 +1105,26 @@ function toggleSettings(){
} }
} }
function _resetSettingsPanelState(){
_settingsSection = 'conversation';
switchSettingsSection('conversation');
const bar=$('settingsUnsavedBar');
if(bar) bar.style.display='none';
}
function _hideSettingsPanel(){
const overlay=$('settingsOverlay');
if(!overlay) return;
_resetSettingsPanelState();
overlay.style.display='none';
}
// Close with unsaved-changes check. If dirty, show a confirm dialog. // Close with unsaved-changes check. If dirty, show a confirm dialog.
function _closeSettingsPanel(){ function _closeSettingsPanel(){
if(!_settingsDirty){ if(!_settingsDirty){
// Nothing changed -- revert any live preview and close // Nothing changed -- revert any live preview and close
_revertSettingsPreview(); _revertSettingsPreview();
$('settingsOverlay').style.display='none'; _hideSettingsPanel();
return; return;
} }
// Dirty -- show inline confirm bar // Dirty -- show inline confirm bar
@@ -971,14 +1152,14 @@ function _showSettingsUnsavedBar(){
+ '<button onclick="_discardSettings()" style="padding:5px 12px;border-radius:6px;border:1px solid var(--border2);background:rgba(255,255,255,.06);color:var(--muted);cursor:pointer;font-size:12px;font-weight:600">Discard</button>' + '<button onclick="_discardSettings()" style="padding:5px 12px;border-radius:6px;border:1px solid var(--border2);background:rgba(255,255,255,.06);color:var(--muted);cursor:pointer;font-size:12px;font-weight:600">Discard</button>'
+ '<button onclick="saveSettings(true)" style="padding:5px 12px;border-radius:6px;border:none;background:var(--accent);color:#fff;cursor:pointer;font-size:12px;font-weight:600">Save</button>' + '<button onclick="saveSettings(true)" style="padding:5px 12px;border-radius:6px;border:none;background:var(--accent);color:#fff;cursor:pointer;font-size:12px;font-weight:600">Save</button>'
+ '</span>'; + '</span>';
const body = document.querySelector('.settings-body') || document.querySelector('.settings-panel'); const body = document.querySelector('.settings-main') || document.querySelector('.settings-body') || document.querySelector('.settings-panel');
if(body) body.prepend(bar); if(body) body.prepend(bar);
} }
function _discardSettings(){ function _discardSettings(){
_revertSettingsPreview(); _revertSettingsPreview();
_settingsDirty = false; _settingsDirty = false;
$('settingsOverlay').style.display = 'none'; _hideSettingsPanel();
} }
// Mark settings as dirty whenever anything changes // Mark settings as dirty whenever anything changes
@@ -1058,6 +1239,8 @@ async function loadSettingsPanel(){
const disableBtn=$('btnDisableAuth'); const disableBtn=$('btnDisableAuth');
if(disableBtn) disableBtn.style.display=active?'':'none'; if(disableBtn) disableBtn.style.display=active?'':'none';
}catch(e){} }catch(e){}
_syncHermesPanelSessionActions();
switchSettingsSection(_settingsSection);
}catch(e){ }catch(e){
showToast(t('settings_load_failed')+e.message); showToast(t('settings_load_failed')+e.message);
} }
@@ -1097,8 +1280,7 @@ async function saveSettings(andClose){
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM(); if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
showToast(t('settings_saved_pw')); showToast(t('settings_saved_pw'));
_settingsDirty=false; _settingsThemeOnOpen=theme; _settingsDirty=false; _settingsThemeOnOpen=theme;
const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none'; _hideSettingsPanel();
$('settingsOverlay').style.display='none';
return; return;
}catch(e){showToast('Save failed: '+e.message);return;} }catch(e){showToast('Save failed: '+e.message);return;}
} }
@@ -1121,7 +1303,7 @@ async function saveSettings(andClose){
if(typeof syncTopbar==='function') syncTopbar(); if(typeof syncTopbar==='function') syncTopbar();
if(typeof renderSessionList==='function') renderSessionList(); if(typeof renderSessionList==='function') renderSessionList();
showToast(t('settings_saved')); showToast(t('settings_saved'));
$('settingsOverlay').style.display='none'; _hideSettingsPanel();
}catch(e){ }catch(e){
showToast(t('settings_save_failed')+e.message); showToast(t('settings_save_failed')+e.message);
} }
@@ -1172,8 +1354,7 @@ function startCronPolling(){
const data=await api(`/api/crons/recent?since=${_cronPollSince}`); const data=await api(`/api/crons/recent?since=${_cronPollSince}`);
if(data.completions&&data.completions.length>0){ if(data.completions&&data.completions.length>0){
for(const c of data.completions){ for(const c of data.completions){
const icon=c.status==='error'?'\u274c':'\u2705'; showToast(`Cron "${c.name}" ${c.status==='error'?'failed':'completed'}`,4000);
showToast(`${icon} Cron "${c.name}" ${c.status==='error'?'failed':'completed'}`,4000);
_cronPollSince=Math.max(_cronPollSince,c.completed_at); _cronPollSince=Math.max(_cronPollSince,c.completed_at);
} }
_cronUnreadCount+=data.completions.length; _cronUnreadCount+=data.completions.length;

View File

@@ -10,7 +10,75 @@ const ICONS={
more:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><circle cx="8" cy="3" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>', more:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><circle cx="8" cy="3" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>',
}; };
async function newSession(flash){
MSG_QUEUE.length=0;updateQueueBadge();
S.toolCalls=[];
clearLiveToolCards();
// Use profile default workspace for new sessions after a profile switch (one-shot),
// otherwise inherit from the current session (or let server pick the default)
const inheritWs=S._profileDefaultWorkspace||(S.session?S.session.workspace:null);
S._profileDefaultWorkspace=null; // consume — only applies to the first new session after switch
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs})});
S.session=data.session;S.messages=data.session.messages||[];
if(flash)S.session._flash=true;
localStorage.setItem('hermes-webui-session',S.session.session_id);
syncTopbar();await loadDir('.');renderMessages();
// don't call renderSessionList here - callers do it when needed
}
async function loadSession(sid){
stopApprovalPolling();hideApprovalCard();
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
S.session=data.session;
localStorage.setItem('hermes-webui-session',S.session.session_id);
// B9: sanitize empty assistant messages that can appear when agent only ran tool calls
data.session.messages=(data.session.messages||[]).filter(m=>{
if(!m||!m.role)return false;
if(m.role==='tool')return false;
if(m.role==='assistant'){let c=m.content||'';if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('');return String(c).trim().length>0;}
return true;
});
if(INFLIGHT[sid]){
S.messages=INFLIGHT[sid].messages;
// Restore live tool cards for this in-flight session
clearLiveToolCards();
for(const tc of (S.toolCalls||[])){
if(tc&&tc.name) appendLiveToolCard(tc);
}
syncTopbar();await loadDir('.');renderMessages();appendThinking();
setBusy(true);setComposerStatus('');
startApprovalPolling(sid);
}else{
MSG_QUEUE.length=0;updateQueueBadge(); // clear queue for the viewed session
S.messages=data.session.messages||[];
S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));
// Reset per-session visual state: the viewed session is idle even if another
// session's stream is still running in the background.
// We directly update the DOM instead of calling setBusy(false), because
// setBusy(false) drains MSG_QUEUE which we don't want here.
S.busy=false;
S.activeStreamId=null;
updateSendBtn();
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
setStatus('');
setComposerStatus('');
clearLiveToolCards();
syncTopbar();await loadDir('.');renderMessages();highlightCode();
}
// Sync context usage indicator from session data
const _s=S.session;
if(_s&&typeof _syncCtxIndicator==='function'){
const u=S.lastUsage||{};
_syncCtxIndicator({input_tokens:_s.input_tokens||u.input_tokens||0,output_tokens:_s.output_tokens||u.output_tokens||0,estimated_cost:_s.estimated_cost||u.estimated_cost,context_length:u.context_length||0,last_prompt_tokens:u.last_prompt_tokens||0,threshold_tokens:u.threshold_tokens||0});
}
}
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)
let _showAllProfiles = false; // false = filter to active profile only
let _sessionActionMenu = null; let _sessionActionMenu = null;
let _sessionActionAnchor = null; let _sessionActionAnchor = null;
let _sessionActionSessionId = null; let _sessionActionSessionId = null;
@@ -170,71 +238,6 @@ window.addEventListener('resize',()=>{
if(_sessionActionMenu && _sessionActionAnchor) _positionSessionActionMenu(_sessionActionAnchor); if(_sessionActionMenu && _sessionActionAnchor) _positionSessionActionMenu(_sessionActionAnchor);
}); });
async function newSession(flash){
MSG_QUEUE.length=0;updateQueueBadge();
S.toolCalls=[];
clearLiveToolCards();
// Use profile default workspace for new sessions after a profile switch (one-shot),
// otherwise inherit from the current session (or let server pick the default)
const inheritWs=S._profileDefaultWorkspace||(S.session?S.session.workspace:null);
S._profileDefaultWorkspace=null; // consume — only applies to the first new session after switch
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs})});
S.session=data.session;S.messages=data.session.messages||[];
if(flash)S.session._flash=true;
localStorage.setItem('hermes-webui-session',S.session.session_id);
syncTopbar();await loadDir('.');renderMessages();
// don't call renderSessionList here - callers do it when needed
}
async function loadSession(sid){
stopApprovalPolling();hideApprovalCard();
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
S.session=data.session;
localStorage.setItem('hermes-webui-session',S.session.session_id);
// B9: sanitize empty assistant messages that can appear when agent only ran tool calls
data.session.messages=(data.session.messages||[]).filter(m=>{
if(!m||!m.role)return false;
if(m.role==='tool')return false;
if(m.role==='assistant'){let c=m.content||'';if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('');return String(c).trim().length>0;}
return true;
});
if(INFLIGHT[sid]){
S.messages=INFLIGHT[sid].messages;
// Restore live tool cards for this in-flight session
clearLiveToolCards();
for(const tc of (S.toolCalls||[])){
if(tc&&tc.name) appendLiveToolCard(tc);
}
syncTopbar();await loadDir('.');renderMessages();appendThinking();
setBusy(true);setStatus((window._botName||'Hermes')+' is thinking\u2026');
startApprovalPolling(sid);
}else{
MSG_QUEUE.length=0;updateQueueBadge(); // clear queue for the viewed session
S.messages=data.session.messages||[];
S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));
// Reset per-session visual state: the viewed session is idle even if another
// session's stream is still running in the background.
// We directly update the DOM instead of calling setBusy(false), because
// setBusy(false) drains MSG_QUEUE which we don't want here.
S.busy=false;
S.activeStreamId=null;
$('btnSend').disabled=false;
$('btnSend').style.opacity='1';
const _dots=$('activityDots');if(_dots)_dots.style.display='none';
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
setStatus('');
clearLiveToolCards();
syncTopbar();await loadDir('.');renderMessages();highlightCode();
}
}
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)
let _showAllProfiles = false; // false = filter to active profile only
async function renderSessionList(){ async function renderSessionList(){
try{ try{
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = []; if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
@@ -300,6 +303,7 @@ function filterSessions(){
function renderSessionListFromCache(){ function renderSessionListFromCache(){
// Don't re-render while user is actively renaming a session (would destroy the input) // Don't re-render while user is actively renaming a session (would destroy the input)
if(_renamingSid) return; if(_renamingSid) return;
closeSessionActionMenu();
const q=($('sessionSearch').value||'').toLowerCase(); const q=($('sessionSearch').value||'').toLowerCase();
const titleMatches=q?_allSessions.filter(s=>(s.title||'Untitled').toLowerCase().includes(q)):_allSessions; const titleMatches=q?_allSessions.filter(s=>(s.title||'Untitled').toLowerCase().includes(q)):_allSessions;
// Merge content matches (deduped): content matches appended after title matches // Merge content matches (deduped): content matches appended after title matches
@@ -463,6 +467,7 @@ function renderSessionListFromCache(){
// Rename: called directly when we confirm it's a double-click // Rename: called directly when we confirm it's a double-click
const startRename=()=>{ const startRename=()=>{
closeSessionActionMenu();
_renamingSid = s.session_id; _renamingSid = s.session_id;
const inp=document.createElement('input'); const inp=document.createElement('input');
inp.className='session-title-input'; inp.className='session-title-input';
@@ -501,11 +506,10 @@ function renderSessionListFromCache(){
pinInd.innerHTML=ICONS.pin; pinInd.innerHTML=ICONS.pin;
el.appendChild(pinInd); el.appendChild(pinInd);
} }
// Project indicator: colored left border (active item keeps its own gold color) // Project indicator: colored dot appended after the title
if(s.project_id){ if(s.project_id){
const proj=_allProjects.find(p=>p.project_id===s.project_id); const proj=_allProjects.find(p=>p.project_id===s.project_id);
if(proj){ if(proj){
// project color shown via dot indicator, not left border
const dot=document.createElement('span'); const dot=document.createElement('span');
dot.className='session-project-dot'; dot.className='session-project-dot';
dot.style.background=proj.color||'var(--blue)'; dot.style.background=proj.color||'var(--blue)';
@@ -514,6 +518,7 @@ function renderSessionListFromCache(){
} }
} }
el.appendChild(title); el.appendChild(title);
// Single trigger button that opens a shared dropdown menu
const actions=document.createElement('div'); const actions=document.createElement('div');
actions.className='session-actions'; actions.className='session-actions';
const menuBtn=document.createElement('button'); const menuBtn=document.createElement('button');
@@ -564,8 +569,12 @@ function renderSessionListFromCache(){
} }
async function deleteSession(sid){ async function deleteSession(sid){
const _delSess=await showConfirmDialog({title:'Delete conversation',message:'This cannot be undone.',confirmLabel:'Delete',danger:true,focusCancel:true}); const ok=await showConfirmDialog({
if(!_delSess) return; message:'Delete this conversation?',
confirmLabel:t('delete_title'),
danger:true
});
if(!ok)return;
try{ try{
await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})}); await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})});
}catch(e){setStatus(`Delete failed: ${e.message}`);return;} }catch(e){setStatus(`Delete failed: ${e.message}`);return;}
@@ -640,8 +649,11 @@ function _showProjectPicker(session, anchorEl){
createItem.onclick=async()=>{ createItem.onclick=async()=>{
picker.remove(); picker.remove();
document.removeEventListener('click',close); document.removeEventListener('click',close);
// Prompt for name inline const name=await showPromptDialog({
const name=await showPromptDialog({title:'New project',message:'',placeholder:'Project name',confirmLabel:t('create')}); message:t('project_name_prompt'),
confirmLabel:t('create'),
placeholder:'Project name'
});
if(!name||!name.trim()) return; if(!name||!name.trim()) return;
const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length]; 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})}); const res=await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:name.trim(),color})});
@@ -680,7 +692,7 @@ function _showProjectPicker(session, anchorEl){
setTimeout(()=>document.addEventListener('click',close),0); setTimeout(()=>document.addEventListener('click',close),0);
} }
async function _startProjectCreate(bar, addBtn){ function _startProjectCreate(bar, addBtn){
const inp=document.createElement('input'); const inp=document.createElement('input');
inp.className='project-create-input'; inp.className='project-create-input';
inp.placeholder='Project name'; inp.placeholder='Project name';
@@ -727,12 +739,14 @@ function _startProjectRename(proj, chip){
} }
async function _confirmDeleteProject(proj){ async function _confirmDeleteProject(proj){
const _delProj=await showConfirmDialog({title:`Delete project "${proj.name}"?`,message:'Sessions will be unassigned but not deleted.',confirmLabel:'Delete',danger:true,focusCancel:true}); const ok=await showConfirmDialog({
if(!_delProj) return; message:'Delete project "'+proj.name+'"? Sessions will be unassigned but not deleted.',
confirmLabel:t('delete_title'),
danger:true
});
if(!ok){return;}
await api('/api/projects/delete',{method:'POST',body:JSON.stringify({project_id:proj.project_id})}); await api('/api/projects/delete',{method:'POST',body:JSON.stringify({project_id:proj.project_id})});
if(_activeProject===proj.project_id) _activeProject=null; if(_activeProject===proj.project_id) _activeProject=null;
await renderSessionList(); await renderSessionList();
showToast('Project deleted'); showToast('Project deleted');
} }

View File

@@ -34,10 +34,11 @@
:root[data-theme="light"] .session-item{color:#5a544a;} :root[data-theme="light"] .session-item{color:#5a544a;}
:root[data-theme="light"] .session-item:hover{background:rgba(0,0,0,.06);color:#2c2825;} :root[data-theme="light"] .session-item:hover{background:rgba(0,0,0,.06);color:#2c2825;}
:root[data-theme="light"] .session-item.active{background:rgba(45,111,163,.1);color:#1a5a8a;} :root[data-theme="light"] .session-item.active{background:rgba(45,111,163,.1);color:#1a5a8a;}
:root[data-theme="light"] .session-item.active .session-actions{background:linear-gradient(to right,transparent,rgba(228,224,216,.95) 12px);}
:root[data-theme="light"] .session-pin-indicator{color:#996b15;} :root[data-theme="light"] .session-pin-indicator{color:#996b15;}
:root[data-theme="light"] .session-date-header.pinned{color:#996b15;} :root[data-theme="light"] .session-date-header.pinned{color:#996b15;}
:root[data-theme="light"] .session-actions .act-pin.pinned{color:#996b15;} :root[data-theme="light"] .session-actions-trigger.active,
:root[data-theme="light"] .session-item.menu-open .session-actions-trigger{background:rgba(45,111,163,.12);border-color:rgba(45,111,163,.22);color:#1a5a8a;}
:root[data-theme="light"] .session-action-opt.is-active{background:rgba(45,111,163,.1);}
:root[data-theme="light"] .msg-role.user{color:#2d6fa3;} :root[data-theme="light"] .msg-role.user{color:#2d6fa3;}
:root[data-theme="light"] .msg-role.assistant{color:#8a6520;} :root[data-theme="light"] .msg-role.assistant{color:#8a6520;}
:root[data-theme="light"] .role-icon.user{background:rgba(45,111,163,.12);color:#2d6fa3;border-color:rgba(45,111,163,.25);} :root[data-theme="light"] .role-icon.user{background:rgba(45,111,163,.12);color:#2d6fa3;border-color:rgba(45,111,163,.25);}
@@ -67,7 +68,8 @@
:root[data-theme="light"] .preview-md th{background:rgba(0,0,0,.04);} :root[data-theme="light"] .preview-md th{background:rgba(0,0,0,.04);}
:root[data-theme="light"] .preview-md td{border-color:rgba(0,0,0,.08);} :root[data-theme="light"] .preview-md td{border-color:rgba(0,0,0,.08);}
:root[data-theme="light"] .preview-badge.code{background:rgba(0,0,0,.05);} :root[data-theme="light"] .preview-badge.code{background:rgba(0,0,0,.05);}
:root[data-theme="light"] .ctx-bar-wrap{background:rgba(0,0,0,.08);} :root[data-theme="light"] .ctx-ring-center{background:var(--bg);color:#5a544a;}
:root[data-theme="light"] .ctx-ring-track{stroke:rgba(0,0,0,.12);}
:root[data-theme="light"] .ws-opt:hover{background:rgba(0,0,0,.05);} :root[data-theme="light"] .ws-opt:hover{background:rgba(0,0,0,.05);}
:root[data-theme="light"] .profile-opt:hover{background:rgba(0,0,0,.05);} :root[data-theme="light"] .profile-opt:hover{background:rgba(0,0,0,.05);}
:root[data-theme="light"] .profile-opt.active{background:rgba(45,111,163,.06);} :root[data-theme="light"] .profile-opt.active{background:rgba(45,111,163,.06);}
@@ -120,16 +122,16 @@
.new-chat-btn:hover{background:rgba(124,185,255,0.13);border-color:rgba(124,185,255,.3);} .new-chat-btn:hover{background:rgba(124,185,255,0.13);border-color:rgba(124,185,255,.3);}
.session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;} .session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;}
.session-search{padding:4px 10px 8px;flex-shrink:0;} .session-search{padding:4px 10px 8px;flex-shrink:0;}
.session-search input{width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 12px;font-size:12px;outline:none;transition:all .15s;} .session-search input{width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:10px 12px;font-size:12px;outline:none;transition:all .15s;}
.session-search input:focus{border-color:rgba(124,185,255,.35);background:var(--hover-bg);box-shadow:0 0 0 2px rgba(124,185,255,.07);} .session-search input:focus{border-color:rgba(124,185,255,.35);background:var(--hover-bg);box-shadow:0 0 0 2px rgba(124,185,255,.07);}
.session-search input::placeholder{color:var(--muted);opacity:.7;} .session-search input::placeholder{color:var(--muted);opacity:.7;}
/* Inline session title edit */ /* Inline session title edit */
.session-title-input{flex:1;background:var(--surface);border:1px solid rgba(124,185,255,.6);border-radius:6px;color:var(--text);padding:3px 8px;font-size:13px;outline:none;min-width:0;box-shadow:0 0 0 2px rgba(124,185,255,.15);font-family:inherit;} .session-title-input{flex:1;background:var(--surface);border:1px solid rgba(124,185,255,.6);border-radius:6px;color:var(--text);padding:3px 8px;font-size:13px;outline:none;min-width:0;box-shadow:0 0 0 2px rgba(124,185,255,.15);font-family:inherit;}
.session-item{padding:8px 10px 8px 8px;border-radius:0 8px 8px 0;cursor:pointer;font-size:13px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:background .15s,color .15s,border-color .15s;display:flex;align-items:center;gap:6px;min-width:0;border-left:2px solid transparent;position:relative;} .session-item{padding:8px 40px 8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:background .15s,color .15s;display:flex;align-items:center;gap:6px;min-width:0;position:relative;}
.session-item:hover{background:var(--hover-bg);color:var(--text);} .session-item:hover{background:var(--hover-bg);color:var(--text);}
.session-item.active{background:rgba(232,160,48,0.12);color:#e8a030;border-left:2px solid #e8a030;padding-left:8px;} .session-item.active{background:rgba(232,160,48,0.12);color:#e8a030;}
.session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
/* ── Session action trigger + dropdown (⋯ menu) ── */ /* ── Session action trigger + dropdown ── */
.session-actions{position:absolute;right:6px;top:50%;transform:translateY(-50%);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .15s ease;} .session-actions{position:absolute;right:6px;top:50%;transform:translateY(-50%);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .15s ease;}
.session-item:hover .session-actions,.session-item:focus-within .session-actions,.session-item.menu-open .session-actions{opacity:1;pointer-events:auto;} .session-item:hover .session-actions,.session-item:focus-within .session-actions,.session-item.menu-open .session-actions{opacity:1;pointer-events:auto;}
.session-actions-trigger{width:26px;height:26px;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer;padding:0;line-height:1;display:inline-flex;align-items:center;justify-content:center;transition:background .12s,color .12s,border-color .12s;} .session-actions-trigger{width:26px;height:26px;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer;padding:0;line-height:1;display:inline-flex;align-items:center;justify-content:center;transition:background .12s,color .12s,border-color .12s;}
@@ -157,8 +159,6 @@
.session-date-header.pinned{color:#f5c542;} .session-date-header.pinned{color:#f5c542;}
.session-date-caret{font-size:9px;transition:transform .2s;flex-shrink:0;display:inline-block;} .session-date-caret{font-size:9px;transition:transform .2s;flex-shrink:0;display:inline-block;}
.session-date-caret.collapsed{transform:rotate(-90deg);} .session-date-caret.collapsed{transform:rotate(-90deg);}
/* ── Shared app dialogs (replace native confirm/prompt) ── */
.app-dialog-overlay{position:fixed;inset:0;background:rgba(7,12,19,.62);backdrop-filter:blur(6px);z-index:1100;display:none;align-items:center;justify-content:center;padding:24px;} .app-dialog-overlay{position:fixed;inset:0;background:rgba(7,12,19,.62);backdrop-filter:blur(6px);z-index:1100;display:none;align-items:center;justify-content:center;padding:24px;}
.app-dialog{width:min(460px,100%);background:linear-gradient(180deg,rgba(21,31,45,.98),rgba(13,20,31,.98));border:1px solid rgba(124,185,255,.2);border-radius:18px;box-shadow:0 18px 60px rgba(0,0,0,.45);padding:18px 18px 16px;color:var(--text);} .app-dialog{width:min(460px,100%);background:linear-gradient(180deg,rgba(21,31,45,.98),rgba(13,20,31,.98));border:1px solid rgba(124,185,255,.2);border-radius:18px;box-shadow:0 18px 60px rgba(0,0,0,.45);padding:18px 18px 16px;color:var(--text);}
.app-dialog-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:10px;} .app-dialog-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:10px;}
@@ -218,12 +218,12 @@
.onboarding-actions .sm-btn{padding:10px 16px;} .onboarding-actions .sm-btn{padding:10px 16px;}
.reconnect-banner{display:none;background:var(--surface);border:1px solid rgba(201,168,76,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--gold);display:none;align-items:center;justify-content:space-between;gap:12px;} .reconnect-banner{display:none;background:var(--surface);border:1px solid rgba(201,168,76,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--gold);display:none;align-items:center;justify-content:space-between;gap:12px;}
.reconnect-banner.visible{display:flex;} .reconnect-banner.visible{display:flex;}
.reconnect-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(201,168,76,0.15);border:1px solid rgba(201,168,76,0.4);color:var(--gold);cursor:pointer;} .reconnect-btn{padding:6px 12px;border-radius:8px;font-size:12px;font-weight:600;background:rgba(201,168,76,0.15);border:1px solid rgba(201,168,76,0.4);color:var(--gold);cursor:pointer;}
.reconnect-btn:hover{background:rgba(201,168,76,0.25);} .reconnect-btn:hover{background:rgba(201,168,76,0.25);}
/* ── Update banner ── */ /* ── Update banner ── */
.update-banner{display:none;background:var(--surface);border:1px solid rgba(124,185,255,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--blue);align-items:center;justify-content:space-between;gap:12px;} .update-banner{display:none;background:var(--surface);border:1px solid rgba(124,185,255,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--blue);align-items:center;justify-content:space-between;gap:12px;}
.update-banner.visible{display:flex;} .update-banner.visible{display:flex;}
.update-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(124,185,255,0.1);border:1px solid rgba(124,185,255,0.3);color:var(--blue);cursor:pointer;transition:background .15s;} .update-btn{padding:6px 12px;border-radius:8px;font-size:12px;font-weight:600;background:rgba(124,185,255,0.1);border:1px solid rgba(124,185,255,0.3);color:var(--blue);cursor:pointer;transition:background .15s;}
.update-btn:hover{background:rgba(124,185,255,0.2);} .update-btn:hover{background:rgba(124,185,255,0.2);}
.update-primary{background:rgba(124,185,255,0.2);border-color:rgba(124,185,255,0.5);} .update-primary{background:rgba(124,185,255,0.2);border-color:rgba(124,185,255,0.5);}
.update-btn:disabled{opacity:0.5;cursor:not-allowed;} .update-btn:disabled{opacity:0.5;cursor:not-allowed;}
@@ -235,7 +235,7 @@
.approval-desc{font-size:12px;color:var(--muted);margin-bottom:8px;line-height:1.5;} .approval-desc{font-size:12px;color:var(--muted);margin-bottom:8px;line-height:1.5;}
.approval-cmd{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:var(--pre-text);white-space:pre-wrap;word-break:break-all;margin-bottom:14px;max-height:120px;overflow-y:auto;} .approval-cmd{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:var(--pre-text);white-space:pre-wrap;word-break:break-all;margin-bottom:14px;max-height:120px;overflow-y:auto;}
.approval-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;} .approval-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
.approval-btn{display:inline-flex;align-items:center;gap:6px;padding:7px 15px;border-radius:8px;font-size:12px;font-weight:600;border:1px solid var(--border2);background:var(--hover-bg);color:var(--text);cursor:pointer;transition:all .15s;white-space:nowrap;} .approval-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;font-size:12px;font-weight:600;border:1px solid var(--border2);background:var(--hover-bg);color:var(--text);cursor:pointer;transition:all .15s;white-space:nowrap;}
.approval-btn:hover{background:rgba(255,255,255,0.12);transform:translateY(-1px);box-shadow:0 2px 8px rgba(0,0,0,0.2);} .approval-btn:hover{background:rgba(255,255,255,0.12);transform:translateY(-1px);box-shadow:0 2px 8px rgba(0,0,0,0.2);}
.approval-btn:active{transform:translateY(0);box-shadow:none;} .approval-btn:active{transform:translateY(0);box-shadow:none;}
.approval-btn:disabled{opacity:.5;cursor:not-allowed;transform:none;} .approval-btn:disabled{opacity:.5;cursor:not-allowed;transform:none;}
@@ -255,7 +255,7 @@
.sidebar-nav{display:flex;border-bottom:1px solid var(--border);flex-shrink:0;padding:6px 8px 0;gap:2px;} .sidebar-nav{display:flex;border-bottom:1px solid var(--border);flex-shrink:0;padding:6px 8px 0;gap:2px;}
.nav-tab{flex:1;padding:10px 4px 8px;font-size:20px;text-align:center;cursor:pointer;color:var(--muted);border:none;background:none;transition:color .15s;border-bottom:2px solid transparent;white-space:nowrap;overflow:hidden;position:relative;} .nav-tab{flex:1;padding:10px 4px 8px;font-size:20px;text-align:center;cursor:pointer;color:var(--muted);border:none;background:none;transition:color .15s;border-bottom:2px solid transparent;white-space:nowrap;overflow:hidden;position:relative;}
.nav-tab:hover{color:var(--text);} .nav-tab:hover{color:var(--text);}
.nav-tab:hover::after{content:attr(data-label);position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:var(--surface);border:1px solid rgba(124,185,255,0.3);color:var(--blue);font-size:12px;font-weight:700;letter-spacing:.02em;padding:5px 11px;border-radius:7px;white-space:nowrap;pointer-events:none;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.3);} .nav-tab:hover::after{content:attr(data-label);position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:var(--surface);border:1px solid rgba(124,185,255,0.3);color:var(--blue);font-size:12px;font-weight:700;letter-spacing:.02em;padding:6px 12px;border-radius:8px;white-space:nowrap;pointer-events:none;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.3);}
.nav-tab.active{color:var(--blue);} .nav-tab.active{color:var(--blue);}
.nav-tab.active::before{content:'';position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:20px;height:2px;background:var(--blue);border-radius:2px 2px 0 0;} .nav-tab.active::before{content:'';position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:20px;height:2px;background:var(--blue);border-radius:2px 2px 0 0;}
/* Panel content areas (swapped by tab) */ /* Panel content areas (swapped by tab) */
@@ -265,14 +265,14 @@
.cron-list{flex:1;overflow-y:auto;padding:8px;} .cron-list{flex:1;overflow-y:auto;padding:8px;}
.cron-item{border-radius:10px;border:1px solid var(--border);margin-bottom:6px;overflow:hidden;transition:border-color .15s,background .15s;background:rgba(255,255,255,.02);} .cron-item{border-radius:10px;border:1px solid var(--border);margin-bottom:6px;overflow:hidden;transition:border-color .15s,background .15s;background:rgba(255,255,255,.02);}
.cron-item:hover{border-color:var(--border2);} .cron-item:hover{border-color:var(--border2);}
.cron-header{display:flex;align-items:center;gap:8px;padding:9px 11px;cursor:pointer;} .cron-header{display:flex;align-items:center;gap:8px;padding:10px 12px;cursor:pointer;}
.cron-name{flex:1;font-size:13px;color:var(--text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .cron-name{flex:1;font-size:13px;color:var(--text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.cron-status{font-size:10px;font-weight:700;padding:2px 7px;border-radius:99px;flex-shrink:0;} .cron-status{font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;flex-shrink:0;}
.cron-status.active{background:rgba(34,197,94,.15);color:#4ade80;} .cron-status.active{background:rgba(34,197,94,.15);color:#4ade80;}
.cron-status.paused{background:rgba(201,168,76,.15);color:var(--gold);} .cron-status.paused{background:rgba(201,168,76,.15);color:var(--gold);}
.cron-status.disabled{background:rgba(255,255,255,.07);color:var(--muted);} .cron-status.disabled{background:rgba(255,255,255,.07);color:var(--muted);}
.cron-status.error{background:rgba(233,69,96,.15);color:var(--accent);} .cron-status.error{background:rgba(233,69,96,.15);color:var(--accent);}
.cron-body{display:none;padding:0 11px 10px;border-top:1px solid var(--border);overflow:hidden;} .cron-body{display:none;padding:0 12px 10px;border-top:1px solid var(--border);overflow:hidden;}
.cron-body.open{display:block;} .cron-body.open{display:block;}
.cron-schedule{font-size:11px;color:var(--muted);margin:8px 0 6px;} .cron-schedule{font-size:11px;color:var(--muted);margin:8px 0 6px;}
.cron-prompt{font-size:11px;color:var(--muted);line-height:1.55;max-height:80px;overflow-y:auto;background:rgba(0,0,0,.2);padding:6px 8px;border-radius:6px;white-space:pre-wrap;margin-bottom:8px;box-sizing:border-box;} .cron-prompt{font-size:11px;color:var(--muted);line-height:1.55;max-height:80px;overflow-y:auto;background:rgba(0,0,0,.2);padding:6px 8px;border-radius:6px;white-space:pre-wrap;margin-bottom:8px;box-sizing:border-box;}
@@ -286,13 +286,13 @@
.cron-last-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:4px;} .cron-last-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:4px;}
/* Skills panel */ /* Skills panel */
.skills-search{padding:8px;flex-shrink:0;} .skills-search{padding:8px;flex-shrink:0;}
.skills-search input{width:100%;background:var(--hover-bg);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:6px 10px;font-size:12px;outline:none;} .skills-search input{width:100%;background:var(--hover-bg);border:1px solid var(--border2);border-radius:8px;color:var(--text);padding:8px 10px;font-size:12px;outline:none;}
.skills-search input::placeholder{color:var(--muted);} .skills-search input::placeholder{color:var(--muted);}
.skills-list{flex:1;overflow-y:auto;padding:0 8px 8px;} .skills-list{flex:1;overflow-y:auto;padding:0 8px 8px;}
.skills-category{margin-bottom:4px;} .skills-category{margin-bottom:4px;}
.skills-cat-header{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:8px 6px 4px;cursor:pointer;display:flex;align-items:center;gap:4px;} .skills-cat-header{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:8px 6px 4px;cursor:pointer;display:flex;align-items:center;gap:4px;}
.skills-cat-header:hover{color:var(--text);} .skills-cat-header:hover{color:var(--text);}
.skill-item{padding:7px 10px;border-radius:7px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:flex-start;gap:6px;transition:all .12s;line-height:1.4;} .skill-item{padding:8px 10px;border-radius:8px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:flex-start;gap:6px;transition:all .12s;line-height:1.4;}
.skill-item:hover{background:var(--hover-bg);color:var(--text);} .skill-item:hover{background:var(--hover-bg);color:var(--text);}
.skill-item.active{background:rgba(124,185,255,.1);color:var(--blue);} .skill-item.active{background:rgba(124,185,255,.1);color:var(--blue);}
.skill-name{font-weight:500;flex-shrink:0;} .skill-name{font-weight:500;flex-shrink:0;}
@@ -306,20 +306,32 @@
.memory-content p{margin-bottom:6px;} .memory-content p{margin-bottom:6px;}
.memory-empty{color:var(--muted);font-size:12px;font-style:italic;} .memory-empty{color:var(--muted);font-size:12px;font-style:italic;}
.sidebar-bottom{border-top:1px solid var(--border);padding:12px 14px;flex-shrink:0;position:relative;z-index:10;overflow:visible;} .sidebar-bottom{border-top:1px solid var(--border);padding:12px 14px;flex-shrink:0;position:relative;z-index:10;overflow:visible;}
.hermes-launch-btn{width:100%;display:flex;align-items:center;gap:12px;padding:11px 12px;border-radius:12px;border:1px solid var(--border2);background:linear-gradient(180deg,rgba(255,255,255,.05),rgba(255,255,255,.03));color:var(--text);cursor:pointer;transition:background .15s,border-color .15s,transform .15s;text-align:left;}
.hermes-launch-btn:hover{background:rgba(255,255,255,.08);border-color:rgba(124,185,255,.28);transform:translateY(-1px);}
.hermes-launch-icon{width:32px;height:32px;border-radius:10px;background:linear-gradient(145deg,rgba(124,185,255,.15),rgba(201,168,76,.1));border:1px solid rgba(124,185,255,.2);display:flex;align-items:center;justify-content:center;flex-shrink:0;overflow:hidden;box-shadow:0 4px 16px rgba(124,185,255,.08);}
.hermes-launch-icon svg{display:block;width:22px;height:22px;flex-shrink:0;}
.hermes-launch-copy{display:flex;flex-direction:column;min-width:0;flex:1;}
.hermes-launch-title{font-size:13px;font-weight:700;letter-spacing:.01em;color:var(--text);}
.hermes-launch-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.hermes-launch-chevron{color:var(--muted);flex-shrink:0;}
.field-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;opacity:.8;} .field-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;opacity:.8;}
select{width:100%;background:var(--input-bg);border:1px solid var(--border2);border-radius:8px;color:var(--text);padding:7px 28px 7px 10px;font-size:12px;outline:none;appearance:none;margin-bottom:6px;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238888aa' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;} select{width:100%;background:var(--input-bg);border:1px solid var(--border2);border-radius:8px;color:var(--text);padding:8px 28px 8px 10px;font-size:12px;outline:none;appearance:none;margin-bottom:6px;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238888aa' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;}
select:focus{border-color:rgba(124,185,255,.4);box-shadow:0 0 0 2px rgba(124,185,255,.08);} select:focus{border-color:rgba(124,185,255,.4);box-shadow:0 0 0 2px rgba(124,185,255,.08);}
optgroup{color:var(--muted);font-size:11px;font-weight:700;} optgroup{color:var(--muted);font-size:11px;font-weight:700;}
option{background:var(--bg);color:var(--text);padding:6px;} option{background:var(--bg);color:var(--text);padding:6px;}
.sidebar-actions{display:flex;gap:6px;} .sidebar-actions{display:flex;gap:6px;}
.sm-btn{flex:1;padding:7px 0;border-radius:8px;font-size:11px;font-weight:500;background:var(--input-bg);border:1px solid var(--border);color:var(--muted);cursor:pointer;transition:all .15s;text-align:center;letter-spacing:.02em;} .sm-btn{flex:1;padding:8px 0;border-radius:8px;font-size:11px;font-weight:500;background:var(--input-bg);border:1px solid var(--border);color:var(--muted);cursor:pointer;transition:all .15s;text-align:center;letter-spacing:.02em;}
.sm-btn:hover{background:rgba(255,255,255,0.09);color:var(--text);border-color:rgba(255,255,255,.15);} .sm-btn:hover{background:rgba(255,255,255,0.09);color:var(--text);border-color:rgba(255,255,255,.15);}
.sm-btn:disabled{opacity:.45;cursor:not-allowed;}
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;background:var(--main-bg);} .main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;background:var(--main-bg);}
.topbar{padding:12px 20px;border-bottom:1px solid var(--border);background:var(--topbar-bg);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;position:relative;z-index:10;} .topbar{padding:12px 20px;border-bottom:1px solid var(--border);background:var(--topbar-bg);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;position:relative;z-index:10;}
.topbar-title{font-size:15px;font-weight:600;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .topbar-title{font-size:15px;font-weight:600;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.topbar-meta{font-size:11px;color:var(--muted);margin-top:3px;opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .topbar-meta{font-size:11px;color:var(--muted);margin-top:3px;opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.topbar-chips{display:flex;gap:6px;align-items:center;flex-shrink:0;} .topbar-chips{display:flex;gap:6px;align-items:center;flex-shrink:0;}
.chip{font-size:11px;padding:4px 10px;border-radius:999px;background:rgba(255,255,255,0.05);border:1px solid var(--border2);color:var(--muted);font-weight:500;} .chip{font-size:11px;padding:4px 10px;border-radius:999px;background:rgba(255,255,255,0.05);border:1px solid var(--border2);color:var(--muted);font-weight:500;}
.workspace-toggle-btn{display:inline-flex!important;align-items:center;gap:6px;cursor:pointer;}
.workspace-toggle-btn.active{color:var(--blue);border-color:rgba(124,185,255,.35);background:rgba(124,185,255,.1);}
.workspace-toggle-btn:disabled{opacity:.38;cursor:not-allowed;}
.chip.model{color:var(--blue);border-color:rgba(124,185,255,0.35);background:rgba(124,185,255,0.1);} .chip.model{color:var(--blue);border-color:rgba(124,185,255,0.35);background:rgba(124,185,255,0.1);}
.messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;} .messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;}
.messages-inner{margin:0 auto;width:100%;padding:20px 24px 32px;display:flex;flex-direction:column;} .messages-inner{margin:0 auto;width:100%;padding:20px 24px 32px;display:flex;flex-direction:column;}
@@ -359,7 +371,7 @@
.empty-state h2{font-size:20px;color:var(--text);font-weight:700;letter-spacing:-.02em;} .empty-state h2{font-size:20px;color:var(--text);font-weight:700;letter-spacing:-.02em;}
.empty-state p{font-size:14px;text-align:center;max-width:320px;} .empty-state p{font-size:14px;text-align:center;max-width:320px;}
.suggestion-grid{display:flex;flex-direction:column;gap:8px;margin-top:12px;width:100%;max-width:380px;} .suggestion-grid{display:flex;flex-direction:column;gap:8px;margin-top:12px;width:100%;max-width:380px;}
.suggestion{padding:11px 14px;background:var(--input-bg);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--muted);cursor:pointer;transition:all .15s;text-align:left;} .suggestion{padding:12px 14px;background:var(--input-bg);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--muted);cursor:pointer;transition:all .15s;text-align:left;}
.suggestion:hover{background:rgba(124,185,255,0.07);color:var(--text);border-color:rgba(124,185,255,.3);transform:translateX(2px);} .suggestion:hover{background:rgba(124,185,255,0.07);color:var(--text);border-color:rgba(124,185,255,.3);transform:translateX(2px);}
/* ── Composer ── */ /* ── Composer ── */
.composer-wrap{border-top:1px solid var(--border);padding:12px 20px 16px;background:var(--bg);flex-shrink:0;} .composer-wrap{border-top:1px solid var(--border);padding:12px 20px 16px;background:var(--bg);flex-shrink:0;}
@@ -375,16 +387,53 @@
.attach-chip button:hover{color:var(--accent);} .attach-chip button:hover{color:var(--accent);}
textarea#msg{width:100%;background:transparent;border:none;outline:none;color:var(--text);font-size:14px;line-height:1.65;padding:12px 16px 6px;resize:none;min-height:44px;max-height:200px;font-family:inherit;} textarea#msg{width:100%;background:transparent;border:none;outline:none;color:var(--text);font-size:14px;line-height:1.65;padding:12px 16px 6px;resize:none;min-height:44px;max-height:200px;font-family:inherit;}
textarea#msg::placeholder{color:var(--muted);} textarea#msg::placeholder{color:var(--muted);}
.composer-footer{display:flex;align-items:center;justify-content:space-between;padding:6px 10px 10px;} .composer-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:6px 10px 10px;position:relative;}
.composer-left{display:flex;gap:2px;align-items:center;} .composer-left{display:flex;align-items:center;gap:4px;min-width:0;flex:1;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;}
.composer-left::-webkit-scrollbar{display:none;}
.composer-divider{width:1px;height:16px;background:var(--border);margin:0 3px;flex-shrink:0;}
.composer-profile-wrap{position:relative;flex:0 1 auto;min-width:0;}
.composer-profile-chip{display:inline-flex;align-items:center;gap:8px;max-width:180px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;}
.composer-profile-chip:hover{background-color:var(--hover-bg);}
.composer-profile-chip.active{background:rgba(168,139,250,.08);border-color:rgba(168,139,250,.22);}
.composer-profile-icon,.composer-profile-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
.composer-profile-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.composer-ws-wrap{position:relative;flex:0 1 auto;min-width:0;}
.composer-workspace-chip{display:inline-flex;align-items:center;gap:8px;max-width:240px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;}
.composer-workspace-chip:hover{color:var(--text);background-color:var(--hover-bg);}
.composer-workspace-chip:disabled{opacity:.45;cursor:not-allowed;}
.composer-workspace-chip:disabled:hover{color:var(--muted);background-color:transparent;}
.composer-workspace-chip.active{color:var(--text);background:rgba(124,185,255,.08);border-color:rgba(124,185,255,.22);}
.composer-workspace-icon,.composer-workspace-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
.composer-workspace-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.composer-model-wrap{position:relative;flex:0 1 auto;min-width:0;}
.composer-model-chip{display:inline-flex;align-items:center;gap:8px;max-width:220px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;}
.composer-model-chip:hover{color:var(--text);background-color:var(--hover-bg);}
.composer-model-chip.active{color:var(--text);background:rgba(124,185,255,.08);border-color:rgba(124,185,255,.22);}
.composer-model-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.composer-model-icon,.composer-model-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
.composer-model-select{position:absolute!important;left:-9999px!important;width:1px!important;height:1px!important;opacity:0!important;pointer-events:none!important;}
.composer-right{display:flex;gap:8px;align-items:center;flex-shrink:0;}
.composer-status{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:170px;}
/* Context usage indicator */ /* Context usage indicator */
.ctx-indicator{display:flex;align-items:center;gap:6px;padding:2px 4px;flex-shrink:1;min-width:0;} .ctx-indicator-wrap{position:relative;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;}
.ctx-bar-wrap{width:70px;height:5px;border-radius:3px;background:rgba(255,255,255,.08);overflow:hidden;flex-shrink:0;} .ctx-indicator{width:34px;height:34px;padding:0;border:none;background:none;color:var(--muted);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity .15s,transform .15s;}
.ctx-bar{display:block;height:100%;border-radius:3px;transition:width .4s ease,background .4s ease;min-width:2px;background:var(--blue);} .ctx-indicator:hover{opacity:.88;transform:translateY(-1px);}
.ctx-bar.ctx-mid{background:#e6a817;} .ctx-ring{position:relative;display:flex;width:24px;height:24px;align-items:center;justify-content:center;}
.ctx-bar.ctx-high{background:#e05252;} .ctx-ring-svg{position:absolute;inset:0;width:24px;height:24px;transform:rotate(-90deg);}
.ctx-label{font-size:9px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums;} .ctx-ring-track,.ctx-ring-value{fill:none;stroke-width:3;}
.composer-right{display:flex;gap:6px;align-items:center;} .ctx-ring-track{stroke:rgba(255,255,255,.12);}
.ctx-ring-value{stroke:var(--muted);stroke-linecap:round;stroke-dasharray:61.261056745;stroke-dashoffset:61.261056745;transition:stroke-dashoffset .45s ease,stroke .25s ease;}
.ctx-ring-center{position:relative;display:flex;width:15px;height:15px;align-items:center;justify-content:center;border-radius:999px;background:var(--bg);font-size:8px;font-weight:600;line-height:1;color:var(--muted);font-variant-numeric:tabular-nums;}
.ctx-indicator.ctx-mid .ctx-ring-value{stroke:#e6a817;}
.ctx-indicator.ctx-high .ctx-ring-value{stroke:#e05252;}
.ctx-tooltip{position:absolute;right:0;bottom:calc(100% + 10px);min-width:210px;max-width:250px;padding:10px 12px;border:1px solid var(--border2);border-radius:12px;background:var(--surface);box-shadow:0 12px 30px rgba(0,0,0,.28);font-size:11px;line-height:1.45;color:var(--muted);opacity:0;transform:translateY(4px);pointer-events:none;transition:opacity .14s ease,transform .14s ease;z-index:30;}
.ctx-tooltip::after{content:'';position:absolute;right:10px;top:100%;border-width:6px 6px 0 6px;border-style:solid;border-color:var(--surface) transparent transparent transparent;}
.ctx-indicator-wrap:hover .ctx-tooltip,.ctx-indicator-wrap:focus-within .ctx-tooltip{opacity:1;transform:translateY(0);}
.ctx-tooltip-title{font-size:12px;font-weight:600;color:var(--text);margin-bottom:5px;}
.ctx-tooltip-line+.ctx-tooltip-line{margin-top:3px;}
.cancel-btn{width:34px;height:34px;border-radius:50%;background:rgba(233,69,96,.88);border:none;color:#fff;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s,transform .15s,box-shadow .15s;box-shadow:0 2px 10px rgba(233,69,96,.28);}
.cancel-btn:hover{background:#e94560;transform:scale(1.06);box-shadow:0 4px 14px rgba(233,69,96,.38);}
.cancel-btn:active{transform:scale(.96);}
.icon-btn{width:34px;height:34px;border-radius:8px;background:none;border:none;color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;transition:all .15s;} .icon-btn{width:34px;height:34px;border-radius:8px;background:none;border:none;color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;transition:all .15s;}
.icon-btn{opacity:.75;} .icon-btn{opacity:.75;}
.icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);opacity:1;} .icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);opacity:1;}
@@ -403,14 +452,15 @@
.upload-bar-wrap{display:none;height:3px;background:var(--hover-bg);border-radius:0 0 16px 16px;overflow:hidden;} .upload-bar-wrap{display:none;height:3px;background:var(--hover-bg);border-radius:0 0 16px 16px;overflow:hidden;}
.upload-bar-wrap.active{display:block;} .upload-bar-wrap.active{display:block;}
.upload-bar{height:100%;background:linear-gradient(90deg,var(--blue),#a0d0ff);width:0%;transition:width .3s ease;} .upload-bar{height:100%;background:linear-gradient(90deg,var(--blue),#a0d0ff);width:0%;transition:width .3s ease;}
.rightpanel{width:300px;background:var(--sidebar);border-left:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;} .rightpanel{width:300px;background:var(--sidebar);border-left:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;min-width:0;opacity:1;transform:translateX(0);transform-origin:right center;transition:width .24s cubic-bezier(.22,1,.36,1),opacity .18s ease,transform .24s cubic-bezier(.22,1,.36,1),border-color .24s ease;}
.panel-header{padding:12px 16px;border-bottom:1px solid var(--border);font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;display:flex;align-items:center;justify-content:space-between;} .panel-header{padding:12px 16px;border-bottom:1px solid var(--border);font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;display:flex;align-items:center;justify-content:space-between;}
.git-badge{font-size:9px;font-weight:600;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;letter-spacing:.02em;margin-left:auto;margin-right:4px;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;} .git-badge{font-size:9px;font-weight:600;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;letter-spacing:.02em;margin-left:auto;margin-right:4px;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;}
.git-badge.dirty{color:var(--gold);background:rgba(201,168,76,.1);} .git-badge.dirty{color:var(--gold);background:rgba(201,168,76,.1);}
.panel-actions{display:flex;gap:4px;} .panel-actions{display:flex;gap:4px;}
.panel-icon-btn{width:24px;height:24px;background:none;border:none;color:var(--muted);cursor:pointer;border-radius:5px;font-size:13px;display:flex;align-items:center;justify-content:center;transition:all .15s;} .panel-icon-btn{width:24px;height:24px;background:none;border:none;color:var(--muted);cursor:pointer;border-radius:5px;font-size:13px;display:flex;align-items:center;justify-content:center;transition:all .15s;}
.panel-icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);} .panel-icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);}
.mobile-close-btn{display:none;} .panel-icon-btn:disabled{opacity:.35;cursor:not-allowed;}
.panel-icon-btn:disabled:hover{background:none;color:var(--muted);}
/* File row actions (shown on hover) */ /* File row actions (shown on hover) */
/* file-item-actions removed: delete button is now a flex child */ /* file-item-actions removed: delete button is now a flex child */
.file-action-btn{width:20px;height:20px;background:rgba(0,0,0,.4);border:none;border-radius:4px;color:var(--muted);cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;} .file-action-btn{width:20px;height:20px;background:rgba(0,0,0,.4);border:none;border-radius:4px;color:var(--muted);cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;}
@@ -424,7 +474,7 @@
.breadcrumb-current{color:var(--text);font-weight:500;} .breadcrumb-current{color:var(--text);font-weight:500;}
.breadcrumb-sep{color:var(--border);margin:0 1px;font-size:11px;} .breadcrumb-sep{color:var(--border);margin:0 1px;font-size:11px;}
.file-tree{flex:1;overflow-y:auto;padding:8px;} .file-tree{flex:1;overflow-y:auto;padding:8px;}
.file-item{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:7px;cursor:pointer;font-size:12px;color:var(--muted);transition:all .12s;min-width:0;} .file-item{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;cursor:pointer;font-size:12px;color:var(--muted);transition:all .12s;min-width:0;}
.file-item:hover{background:rgba(255,255,255,.07);color:var(--text);} .file-item:hover{background:rgba(255,255,255,.07);color:var(--text);}
.file-item.active{background:rgba(124,185,255,.12);color:var(--blue);} .file-item.active{background:rgba(124,185,255,.12);color:var(--blue);}
.file-tree-toggle{font-size:10px;color:var(--muted);flex-shrink:0;width:10px;text-align:center;line-height:1;} .file-tree-toggle{font-size:10px;color:var(--muted);flex-shrink:0;width:10px;text-align:center;line-height:1;}
@@ -470,7 +520,11 @@
.mobile-overlay{display:none;} .mobile-overlay{display:none;}
.mobile-bottom-nav{display:none;} .mobile-bottom-nav{display:none;}
@media(max-width:900px){.rightpanel{display:none}.mobile-files-btn{display:inline-flex!important;}} @media(min-width:901px){
.layout.workspace-panel-collapsed .rightpanel{width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;}
}
@media(max-width:900px){.rightpanel{display:none}.workspace-toggle-btn,.mobile-files-btn{display:inline-flex!important;}}
@media(max-width:640px){ @media(max-width:640px){
/* ── Sidebar: slide-in overlay instead of hidden ── */ /* ── Sidebar: slide-in overlay instead of hidden ── */
@@ -488,7 +542,7 @@
z-index:199;-webkit-tap-highlight-color:transparent;} z-index:199;-webkit-tap-highlight-color:transparent;}
.mobile-overlay.visible{display:block;} .mobile-overlay.visible{display:block;}
/* Files button in topbar */ /* Files button in topbar */
.mobile-files-btn{display:inline-flex!important;} .workspace-toggle-btn,.mobile-files-btn{display:inline-flex!important;}
/* Right panel: slide-over from right */ /* Right panel: slide-over from right */
.rightpanel{display:flex!important;position:fixed;right:-320px;top:0;bottom:0; .rightpanel{display:flex!important;position:fixed;right:-320px;top:0;bottom:0;
width:300px;z-index:200;transition:right .25s ease; width:300px;z-index:200;transition:right .25s ease;
@@ -509,14 +563,19 @@
.mobile-nav-btn svg{flex-shrink:0;} .mobile-nav-btn svg{flex-shrink:0;}
/* Hide sidebar nav tabs (replaced by bottom nav) */ /* Hide sidebar nav tabs (replaced by bottom nav) */
.sidebar-nav{display:none;} .sidebar-nav{display:none;}
/* Hide sidebar bottom section on mobile (model select, workspace) */ /* Keep the Hermes control available at the bottom of the mobile sidebar */
.sidebar-bottom{display:none;} .sidebar-bottom{display:block;padding:10px;}
/* Topbar adjustments */ /* Topbar adjustments */
.topbar{padding:8px 12px;gap:8px;} .topbar{padding:8px 12px;gap:8px;}
.topbar-title{font-size:14px;} .topbar-title{font-size:14px;}
.topbar-meta{display:none;} .topbar-meta{display:none;}
.topbar-chips{flex-wrap:nowrap;gap:4px;overflow-x:auto;-webkit-overflow-scrolling:touch;} .topbar-chips{flex-wrap:nowrap;gap:4px;overflow-x:auto;-webkit-overflow-scrolling:touch;}
.topbar-chips .chip,.topbar-chips .ws-chip,.topbar-chips button{font-size:11px!important;padding:3px 8px!important;white-space:nowrap;} .topbar-chips .chip,.topbar-chips .ws-chip,.topbar-chips button{font-size:11px!important;padding:4px 8px!important;white-space:nowrap;}
.settings-shell{grid-template-columns:1fr;gap:0;}
.settings-tabs{flex-direction:row;overflow-x:auto;padding:10px 12px;border-right:none;border-bottom:1px solid var(--border);gap:6px;}
.settings-tab{flex-shrink:0;}
.settings-main{padding:18px 16px;}
.hermes-action-grid{grid-template-columns:1fr;}
/* Messages area — account for bottom nav */ /* Messages area — account for bottom nav */
.messages{padding-bottom:60px;} .messages{padding-bottom:60px;}
.messages-inner{padding:12px 10px 20px;} .messages-inner{padding:12px 10px 20px;}
@@ -526,10 +585,27 @@
.composer-wrap{padding:8px 10px 12px!important;margin-bottom:56px;} .composer-wrap{padding:8px 10px 12px!important;margin-bottom:56px;}
.composer-box{border-radius:12px;} .composer-box{border-radius:12px;}
.composer-box textarea{font-size:16px;min-height:40px;} .composer-box textarea{font-size:16px;min-height:40px;}
.composer-footer{padding:6px 8px 8px!important;gap:8px;}
/* icon-only composer chips below 768px */
.composer-profile-label,
.composer-workspace-label,
.composer-model-label,
.composer-profile-chevron,
.composer-workspace-chevron,
.composer-model-chevron{display:none;}
.composer-profile-chip,
.composer-workspace-chip,
.composer-model-chip{max-width:44px;min-width:44px;min-height:44px;padding:6px;justify-content:center;gap:0;font-size:11px;}
.composer-divider{display:none;}
.composer-status{max-width:96px;font-size:10px;}
.send-btn{width:32px;height:32px;} .send-btn{width:32px;height:32px;}
.cancel-btn{width:32px;height:32px;}
.ctx-indicator{width:32px;height:32px;}
.ctx-tooltip{right:-4px;min-width:190px;max-width:220px;}
/* Touch targets — minimum 44px */ /* Touch targets — minimum 44px */
.icon-btn,.mic-btn{min-width:44px;min-height:44px;} .icon-btn,.mic-btn{min-width:44px;min-height:44px;}
.session-item{min-height:44px;padding:10px 12px;} .session-item{min-height:44px;padding:10px 40px 10px 12px;}
.session-actions{opacity:1;pointer-events:auto;}
/* Empty state */ /* Empty state */
.empty-state h2{font-size:18px;} .empty-state h2{font-size:18px;}
.empty-state p{font-size:13px;} .empty-state p{font-size:13px;}
@@ -556,16 +632,22 @@
.onboarding-actions .sm-btn{width:100%;min-height:44px;} .onboarding-actions .sm-btn{width:100%;min-height:44px;}
/* Login page responsive */ /* Login page responsive */
.card{width:90vw;max-width:320px;padding:28px 24px;} .card{width:90vw;max-width:320px;padding:28px 24px;}
/* Workspace panel mobile close button */
.mobile-close-btn{display:inline-flex;}
/* Profile dropdown — escape overflow-x:auto clipping context */
.profile-dropdown{position:fixed;top:56px;right:8px;left:auto;max-width:calc(100vw - 16px);}
} }
/* ── Workspace dropdown (topbar) ── */ /* ── Workspace dropdown (topbar) ── */
.ws-chip{user-select:none;} .ws-chip{user-select:none;}
.ws-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;right:0;min-width:200px;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;} .ws-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;right:0;min-width:200px;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;}
.ws-dropdown.open{display:block;} .ws-dropdown.open{display:block;}
.ws-dropdown-footer{left:0;right:auto;bottom:calc(100% + 4px);min-width:280px;max-width:min(420px,calc(100vw - 32px));}
.model-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;min-width:280px;max-width:min(420px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;}
.model-dropdown.open{display:block;}
.model-group{padding:8px 14px 4px;font-size:10px;font-weight:700;letter-spacing:.04em;color:var(--muted);text-transform:uppercase;}
.model-opt{padding:10px 14px;cursor:pointer;transition:background .12s;display:flex;flex-direction:column;gap:3px;align-items:flex-start;}
.model-opt:hover{background:rgba(255,255,255,.07);}
.model-opt.active{background:rgba(124,185,255,.1);}
.model-opt-name{display:block;font-size:13px;color:var(--text);font-weight:500;line-height:1.25;}
.model-opt-id{display:block;font-size:10px;color:var(--muted);line-height:1.3;opacity:.72;word-break:break-word;}
.ws-opt{padding:10px 14px;cursor:pointer;transition:background .12s;display:flex;flex-direction:column;gap:4px;align-items:flex-start;} .ws-opt{padding:10px 14px;cursor:pointer;transition:background .12s;display:flex;flex-direction:column;gap:4px;align-items:flex-start;}
.ws-opt:hover{background:rgba(255,255,255,.07);} .ws-opt:hover{background:rgba(255,255,255,.07);}
.ws-opt.active{background:rgba(124,185,255,.1);} .ws-opt.active{background:rgba(124,185,255,.1);}
@@ -573,6 +655,9 @@
.ws-opt-path{display:block;font-size:10px;color:var(--muted);line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:normal;opacity:.72;word-break:break-word;} .ws-opt-path{display:block;font-size:10px;color:var(--muted);line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:normal;opacity:.72;word-break:break-word;}
.ws-divider{height:1px;background:var(--border);margin:4px 0;} .ws-divider{height:1px;background:var(--border);margin:4px 0;}
.ws-manage{color:var(--muted);font-size:12px;} .ws-manage{color:var(--muted);font-size:12px;}
.ws-opt-action{display:flex;flex-direction:row;align-items:center;gap:8px;}
.ws-opt-icon{display:inline-flex;align-items:center;justify-content:center;opacity:.82;flex-shrink:0;}
.ws-opt-meta{font-size:11px;color:var(--muted);}
/* ── Workspace management panel ── */ /* ── Workspace management panel ── */
.ws-row{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border);} .ws-row{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border);}
.ws-row:last-of-type{border-bottom:none;} .ws-row:last-of-type{border-bottom:none;}
@@ -580,13 +665,13 @@
.ws-row-name{font-size:13px;font-weight:500;color:var(--text);} .ws-row-name{font-size:13px;font-weight:500;color:var(--text);}
.ws-row-path{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ws-row-path{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ws-row-actions{display:flex;gap:4px;flex-shrink:0;} .ws-row-actions{display:flex;gap:4px;flex-shrink:0;}
.ws-action-btn{padding:4px 9px;border-radius:6px;font-size:11px;font-weight:600;border:1px solid var(--border2);background:rgba(255,255,255,.05);color:var(--muted);cursor:pointer;transition:all .15s;white-space:nowrap;} .ws-action-btn{padding:4px 8px;border-radius:6px;font-size:11px;font-weight:600;border:1px solid var(--border2);background:rgba(255,255,255,.05);color:var(--muted);cursor:pointer;transition:all .15s;white-space:nowrap;}
.ws-action-btn:hover{background:rgba(255,255,255,.1);color:var(--text);} .ws-action-btn:hover{background:rgba(255,255,255,.1);color:var(--text);}
/* ── Profile dropdown + management panel ── */ /* ── Profile dropdown + management panel ── */
.profile-chip{user-select:none;color:rgba(168,139,250,.9)!important;} .profile-chip{user-select:none;color:rgba(168,139,250,.9)!important;}
.profile-dropdown{display:none;position:absolute;top:calc(100% + 6px);right:0;min-width:260px;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:380px;overflow-y:auto;} .profile-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;min-width:260px;max-width:min(260px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:380px;overflow-y:auto;}
.profile-dropdown.open{display:block;} .profile-dropdown.open{display:block;}
.profile-opt{padding:9px 14px;cursor:pointer;transition:background .12s;} .profile-opt{padding:10px 14px;cursor:pointer;transition:background .12s;}
.profile-opt:hover{background:rgba(255,255,255,.07);} .profile-opt:hover{background:rgba(255,255,255,.07);}
.profile-opt.active{background:rgba(168,139,250,.08);} .profile-opt.active{background:rgba(168,139,250,.08);}
.profile-opt-name{font-size:13px;color:var(--text);font-weight:500;} .profile-opt-name{font-size:13px;color:var(--text);font-weight:500;}
@@ -620,9 +705,9 @@
/* ── Edit message inline ── */ /* ── Edit message inline ── */
.msg-edit-area{width:100%;background:rgba(255,255,255,.05);border:1px solid rgba(124,185,255,.35);border-radius:8px;color:var(--text);padding:10px 12px;font-size:14px;font-family:inherit;line-height:1.6;resize:none;outline:none;min-height:60px;box-sizing:border-box;box-shadow:0 0 0 3px rgba(124,185,255,.07);margin-top:4px;} .msg-edit-area{width:100%;background:rgba(255,255,255,.05);border:1px solid rgba(124,185,255,.35);border-radius:8px;color:var(--text);padding:10px 12px;font-size:14px;font-family:inherit;line-height:1.6;resize:none;outline:none;min-height:60px;box-sizing:border-box;box-shadow:0 0 0 3px rgba(124,185,255,.07);margin-top:4px;}
.msg-edit-bar{display:flex;gap:8px;margin-top:8px;margin-bottom:4px;} .msg-edit-bar{display:flex;gap:8px;margin-top:8px;margin-bottom:4px;}
.msg-edit-send{background:var(--blue);color:#fff;border:none;border-radius:7px;padding:6px 16px;font-size:13px;font-weight:600;cursor:pointer;transition:opacity .15s;} .msg-edit-send{background:var(--blue);color:#fff;border:none;border-radius:8px;padding:6px 16px;font-size:13px;font-weight:600;cursor:pointer;transition:opacity .15s;}
.msg-edit-send:hover{opacity:.85;} .msg-edit-send:hover{opacity:.85;}
.msg-edit-cancel{background:var(--hover-bg);color:var(--muted);border:1px solid var(--border2);border-radius:7px;padding:6px 12px;font-size:13px;cursor:pointer;transition:background .15s;} .msg-edit-cancel{background:var(--hover-bg);color:var(--muted);border:1px solid var(--border2);border-radius:8px;padding:6px 12px;font-size:13px;cursor:pointer;transition:background .15s;}
.msg-edit-cancel:hover{background:rgba(255,255,255,.1);} .msg-edit-cancel:hover{background:rgba(255,255,255,.1);}
/* ── Clear conversation chip ── */ /* ── Clear conversation chip ── */
@@ -686,10 +771,6 @@
/* Empty state: add subtle gradient behind logo */ /* Empty state: add subtle gradient behind logo */
.empty-state{background:radial-gradient(ellipse at 50% 20%,rgba(124,185,255,.04) 0%,transparent 60%);} .empty-state{background:radial-gradient(ellipse at 50% 20%,rgba(124,185,255,.04) 0%,transparent 60%);}
/* ── Activity bar (tool status above composer) ── */
@keyframes fadeIn{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
#activityBar{padding-bottom:8px;flex-shrink:0;}
#activityBarInner{transition:opacity .2s;}
/* Remove old status-text from composer (kept for error messages only) */ /* Remove old status-text from composer (kept for error messages only) */
.status-text{font-size:11px;color:var(--muted);padding-left:2px;display:none;} .status-text{font-size:11px;color:var(--muted);padding-left:2px;display:none;}
@@ -702,7 +783,7 @@
padding: 8px 10px 3px !important; padding: 8px 10px 3px !important;
font-size: 10px !important; font-size: 10px !important;
} }
/* Sidebar bottom: tighten model field */ /* Sidebar bottom: tighten spacing */
.sidebar-bottom { padding: 10px 14px 12px; } .sidebar-bottom { padding: 10px 14px 12px; }
/* Right panel file tree: more padding for breathing room */ /* Right panel file tree: more padding for breathing room */
@@ -805,7 +886,7 @@ body.resizing{user-select:none;cursor:col-resize;}
.tool-card{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:6px;margin:2px 0 2px 40px;overflow:hidden;transition:border-color .15s;} .tool-card{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:6px;margin:2px 0 2px 40px;overflow:hidden;transition:border-color .15s;}
.tool-card:hover{border-color:rgba(255,255,255,.12);} .tool-card:hover{border-color:rgba(255,255,255,.12);}
.tool-card-running{border-color:rgba(124,185,255,.25);background:rgba(124,185,255,.04);} .tool-card-running{border-color:rgba(124,185,255,.25);background:rgba(124,185,255,.04);}
.tool-card-header{display:flex;align-items:center;gap:7px;padding:4px 10px;cursor:pointer;user-select:none;} .tool-card-header{display:flex;align-items:center;gap:8px;padding:4px 10px;cursor:pointer;user-select:none;}
.tool-card-icon{font-size:13px;flex-shrink:0;opacity:.8;} .tool-card-icon{font-size:13px;flex-shrink:0;opacity:.8;}
.tool-card-name{font-size:12px;font-weight:600;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;flex-shrink:0;} .tool-card-name{font-size:12px;font-weight:600;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;flex-shrink:0;}
.tool-card-preview{font-size:11px;color:var(--muted);opacity:.6;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .tool-card-preview{font-size:11px;color:var(--muted);opacity:.6;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
@@ -828,17 +909,38 @@ body.resizing{user-select:none;cursor:col-resize;}
/* ── Settings overlay ── */ /* ── Settings overlay ── */
.settings-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:center;justify-content:center;} .settings-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:center;justify-content:center;}
.settings-panel{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:0;width:420px;max-width:92vw;max-height:92vh;min-height:min(680px,90vh);overflow:visible;box-shadow:0 12px 40px rgba(0,0,0,.5);display:flex;flex-direction:column;} .settings-panel{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:0;width:860px;max-width:92vw;height:min(700px,92vh);overflow:visible;box-shadow:0 12px 40px rgba(0,0,0,.5);display:flex;flex-direction:column;}
.settings-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px 12px;border-bottom:1px solid var(--border);} .settings-header{display:flex;align-items:flex-start;justify-content:space-between;padding:18px 24px 14px;border-bottom:1px solid var(--border);gap:16px;}
.settings-body{padding:20px;overflow-y:auto;flex:1;} .settings-heading{display:flex;flex-direction:column;gap:3px;}
.settings-kicker{font-size:10px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--blue);}
.settings-subtitle{font-size:12px;color:var(--muted);line-height:1.5;}
.settings-body{padding:0;flex:1;display:flex;min-height:0;overflow:hidden;}
.settings-shell{display:grid;grid-template-columns:220px minmax(0,1fr);gap:0;flex:1;min-height:0;min-width:0;}
.settings-tabs{display:flex;flex-direction:column;gap:4px;padding:18px 12px;border-right:1px solid var(--border);align-self:stretch;min-height:0;}
.settings-tab{display:flex;flex-direction:row;gap:12px;align-items:center;padding:10px 12px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--muted);cursor:pointer;transition:background .15s,border-color .15s,color .15s;text-align:left;width:100%;}
.settings-tab:hover{background:rgba(255,255,255,.05);color:var(--text);}
.settings-tab.active{background:rgba(124,185,255,.1);border-color:rgba(124,185,255,.22);color:var(--text);}
.settings-tab-icon{flex-shrink:0;opacity:.9;}
.settings-tab-title{font-size:13px;font-weight:600;letter-spacing:.01em;}
.settings-main{overflow-y:auto;padding:22px 24px;min-width:0;}
.settings-pane{display:none;}
.settings-pane.active{display:block;}
.settings-section-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px;}
.settings-section-title{font-size:13px;font-weight:700;letter-spacing:.01em;color:var(--text);}
.settings-section-meta{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.5;}
.settings-version-badge{display:inline-flex;align-items:center;padding:4px 8px;border-radius:999px;border:1px solid rgba(124,185,255,.22);background:rgba(124,185,255,.08);color:var(--blue);font-size:11px;font-weight:700;flex-shrink:0;}
.hermes-action-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;}
.settings-action-btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:10px 12px;border-radius:10px;border:1px solid var(--border2);background:var(--input-bg);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;transition:background .15s,border-color .15s,color .15s;}
.settings-action-btn:hover{background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.18);}
.settings-action-btn.danger{color:var(--accent);border-color:rgba(233,69,96,.25);}
.settings-action-btn.danger:hover{background:rgba(233,69,96,.08);border-color:rgba(233,69,96,.4);}
.settings-action-btn:disabled,.settings-action-btn.disabled{opacity:.45;cursor:not-allowed;}
.settings-action-btn:disabled:hover,.settings-action-btn.disabled:hover{background:var(--input-bg);border-color:var(--border2);}
.settings-field{margin-bottom:16px;} .settings-field{margin-bottom:16px;}
.settings-field label{display:block;font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin-bottom:6px;} .settings-field label{display:block;font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin-bottom:6px;}
/* Save button inside the settings panel */ /* Save button inside the settings panel */
.settings-panel .settings-btn{background:var(--accent);color:#fff;border:none;border-radius:6px;padding:8px 16px;cursor:pointer;font-weight:600;font-size:13px;} .settings-panel .settings-btn{background:var(--accent);color:#fff;border:none;border-radius:6px;padding:8px 16px;cursor:pointer;font-weight:600;font-size:13px;}
.settings-panel .settings-btn:hover{opacity:.9;} .settings-panel .settings-btn:hover{opacity:.9;}
/* Gear icon in topbar -- muted chip, no red */
.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 indicator (inline, only when pinned) ── */ /* ── 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{flex-shrink:0;color:#f5c542;line-height:1;display:flex;align-items:center;}
@@ -910,7 +1012,6 @@ body.resizing{user-select:none;cursor:col-resize;}
/* ── CLI / Agent session items in sidebar ── */ /* ── CLI / Agent session items in sidebar ── */
.session-item.cli-session { .session-item.cli-session {
border-left-color: var(--gold);
padding-right: 40px; /* make room for the session actions trigger */ padding-right: 40px; /* make room for the session actions trigger */
} }
.session-item.cli-session::after { .session-item.cli-session::after {
@@ -926,7 +1027,10 @@ body.resizing{user-select:none;cursor:col-resize;}
pointer-events: none; /* don't block clicks on session-actions beneath */ pointer-events: none; /* don't block clicks on session-actions beneath */
} }
.session-item.cli-session:hover::after { .session-item.cli-session:hover::after {
display: none; /* hide badge on hover so session-actions icons are fully reachable */ display: none; /* hide badge on hover so the session menu trigger stays clear */
}
.session-item.cli-session.menu-open::after {
display: none;
} }
/* Source-specific colors for gateway sessions */ /* Source-specific colors for gateway sessions */
.session-item.cli-session[data-source="telegram"] { border-left-color: #0088cc; } .session-item.cli-session[data-source="telegram"] { border-left-color: #0088cc; }

View File

@@ -34,6 +34,7 @@ function _applyModelToDropdown(modelId, sel){
const resolved=_findModelInDropdown(modelId,sel); const resolved=_findModelInDropdown(modelId,sel);
if(resolved){ if(resolved){
sel.value=resolved; sel.value=resolved;
if(sel.id==='modelSelect' && typeof syncModelChip==='function') syncModelChip();
return resolved; return resolved;
} }
return null; return null;
@@ -66,9 +67,11 @@ async function populateModelDropdown(){
if(data.default_model && !localStorage.getItem('hermes-webui-model')){ if(data.default_model && !localStorage.getItem('hermes-webui-model')){
_applyModelToDropdown(data.default_model, sel); _applyModelToDropdown(data.default_model, sel);
} }
if(typeof syncModelChip==='function') syncModelChip();
}catch(e){ }catch(e){
// API unavailable -- keep the hardcoded HTML options as fallback // API unavailable -- keep the hardcoded HTML options as fallback
console.warn('Failed to load models from server:',e.message); console.warn('Failed to load models from server:',e.message);
if(typeof syncModelChip==='function') syncModelChip();
} }
} }
@@ -98,6 +101,106 @@ function _checkProviderMismatch(modelId){
return null; return null;
} }
function _selectedModelOption(){
const sel=$('modelSelect');
if(!sel) return null;
return sel.options[sel.selectedIndex]||null;
}
function syncModelChip(){
const sel=$('modelSelect');
const chip=$('composerModelChip');
const label=$('composerModelLabel');
const dd=$('composerModelDropdown');
if(!sel||!chip||!label) return;
const opt=_selectedModelOption();
label.textContent=opt?opt.textContent:getModelLabel(sel.value||'');
chip.title=sel.value||'Conversation model';
chip.classList.toggle('active',!!(dd&&dd.classList.contains('open')));
}
function _positionModelDropdown(){
const dd=$('composerModelDropdown');
const chip=$('composerModelChip');
const footer=document.querySelector('.composer-footer');
if(!dd||!chip||!footer) return;
const chipRect=chip.getBoundingClientRect();
const footerRect=footer.getBoundingClientRect();
let left=chipRect.left-footerRect.left;
const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth);
left=Math.max(0, Math.min(left, maxLeft));
dd.style.left=`${left}px`;
}
function renderModelDropdown(){
const dd=$('composerModelDropdown');
const sel=$('modelSelect');
if(!dd||!sel) return;
dd.innerHTML='';
for(const child of Array.from(sel.children)){
if(child.tagName==='OPTGROUP'){
const heading=document.createElement('div');
heading.className='model-group';
heading.textContent=child.label||'Models';
dd.appendChild(heading);
for(const opt of Array.from(child.children)){
const row=document.createElement('div');
row.className='model-opt'+(opt.value===sel.value?' active':'');
row.innerHTML=`<span class="model-opt-name">${esc(opt.textContent||getModelLabel(opt.value))}</span><span class="model-opt-id">${esc(opt.value)}</span>`;
row.onclick=()=>selectModelFromDropdown(opt.value);
dd.appendChild(row);
}
continue;
}
if(child.tagName==='OPTION'){
const row=document.createElement('div');
row.className='model-opt'+(child.value===sel.value?' active':'');
row.innerHTML=`<span class="model-opt-name">${esc(child.textContent||getModelLabel(child.value))}</span><span class="model-opt-id">${esc(child.value)}</span>`;
row.onclick=()=>selectModelFromDropdown(child.value);
dd.appendChild(row);
}
}
}
async function selectModelFromDropdown(value){
const sel=$('modelSelect');
if(!sel||sel.value===value) { closeModelDropdown(); return; }
sel.value=value;
syncModelChip();
closeModelDropdown();
if(typeof sel.onchange==='function') await sel.onchange();
}
function toggleModelDropdown(){
const dd=$('composerModelDropdown');
const chip=$('composerModelChip');
const sel=$('modelSelect');
if(!dd||!chip||!sel) return;
const open=dd.classList.contains('open');
if(open){closeModelDropdown(); return;}
if(typeof closeProfileDropdown==='function') closeProfileDropdown();
if(typeof closeWsDropdown==='function') closeWsDropdown();
renderModelDropdown();
dd.classList.add('open');
_positionModelDropdown();
chip.classList.add('active');
}
function closeModelDropdown(){
const dd=$('composerModelDropdown');
const chip=$('composerModelChip');
if(dd) dd.classList.remove('open');
if(chip) chip.classList.remove('active');
}
document.addEventListener('click',e=>{
if(!e.target.closest('#composerModelChip') && !e.target.closest('#composerModelDropdown')) closeModelDropdown();
});
window.addEventListener('resize',()=>{
const dd=$('composerModelDropdown');
if(dd&&dd.classList.contains('open')) _positionModelDropdown();
});
// ── Scroll pinning ────────────────────────────────────────────────────────── // ── Scroll pinning ──────────────────────────────────────────────────────────
// When streaming, auto-scroll only if the user hasn't manually scrolled up. // When streaming, auto-scroll only if the user hasn't manually scrolled up.
// Once the user scrolls back to within 80px of the bottom, re-pin. // Once the user scrolls back to within 80px of the bottom, re-pin.
@@ -114,30 +217,59 @@ function _fmtTokens(n){if(!n||n<0)return'0';if(n>=1e6)return(n/1e6).toFixed(1)+'
// Context usage indicator in composer footer // Context usage indicator in composer footer
function _syncCtxIndicator(usage){ function _syncCtxIndicator(usage){
const wrap=$('ctxIndicatorWrap');
const el=$('ctxIndicator'); const el=$('ctxIndicator');
if(!el)return; if(!el)return;
const promptTok=usage.last_prompt_tokens||usage.input_tokens||0; const promptTok=usage.last_prompt_tokens||usage.input_tokens||0;
const totalTok=(usage.input_tokens||0)+(usage.output_tokens||0);
const ctxWindow=usage.context_length||0; const ctxWindow=usage.context_length||0;
if(!promptTok||!ctxWindow){el.style.display='none';return;} const cost=usage.estimated_cost;
el.style.display=''; // Show indicator whenever we have any usage data (tokens or cost)
const pct=Math.min(100,Math.round((promptTok/ctxWindow)*100)); if(!promptTok&&!totalTok&&!cost){
const bar=$('ctxBar'); if(wrap) wrap.style.display='none';
const label=$('ctxLabel'); return;
if(bar){
bar.style.width=pct+'%';
bar.className='ctx-bar'+(pct>75?' ctx-high':pct>50?' ctx-mid':'');
} }
if(label){ if(wrap) wrap.style.display='';
const cost=usage.estimated_cost; const hasCtxWindow=!!(promptTok&&ctxWindow);
let text=`${_fmtTokens(promptTok)} / ${_fmtTokens(ctxWindow)}`; const pct=hasCtxWindow?Math.min(100,Math.round((promptTok/ctxWindow)*100)):0;
if(pct>0) text+=` (${pct}%)`; const ring=$('ctxRingValue');
if(cost) text+=` \u00b7 $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`; const center=$('ctxPercent');
label.textContent=text; const usageLine=$('ctxTooltipUsage');
const tokensLine=$('ctxTooltipTokens');
const thresholdLine=$('ctxTooltipThreshold');
const costLine=$('ctxTooltipCost');
if(ring){
const circumference=61.261056745;
ring.style.strokeDasharray=String(circumference);
ring.style.strokeDashoffset=String(circumference*(1-pct/100));
} }
// Update title with detailed info if(center) center.textContent=hasCtxWindow?String(pct):'\u00b7';
el.classList.toggle('ctx-mid',pct>50&&pct<=75);
el.classList.toggle('ctx-high',pct>75);
let label=hasCtxWindow?`Context window ${pct}% used`:`${_fmtTokens(totalTok)} tokens used`;
if(cost) label+=` \u00b7 $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
el.setAttribute('aria-label',label);
if(usageLine) usageLine.textContent=hasCtxWindow?`${pct}% used (${Math.max(0,100-pct)}% left)`:`${_fmtTokens(totalTok)} tokens used`;
if(tokensLine) tokensLine.textContent=hasCtxWindow?`${_fmtTokens(promptTok)} / ${_fmtTokens(ctxWindow)} tokens used`:`In: ${_fmtTokens(usage.input_tokens||0)} \u00b7 Out: ${_fmtTokens(usage.output_tokens||0)}`;
const threshold=usage.threshold_tokens||0; const threshold=usage.threshold_tokens||0;
el.title=`Context: ${_fmtTokens(promptTok)} of ${_fmtTokens(ctxWindow)} tokens used` if(thresholdLine){
+(threshold?`\nAuto-compress at ${_fmtTokens(threshold)} (${Math.round(threshold/ctxWindow*100)}%)`:''); if(threshold&&ctxWindow){
thresholdLine.style.display='';
thresholdLine.textContent=`Auto-compress at ${_fmtTokens(threshold)} (${Math.round(threshold/ctxWindow*100)}%)`;
}else{
thresholdLine.style.display='none';
thresholdLine.textContent='';
}
}
if(costLine){
if(cost){
costLine.style.display='';
costLine.textContent=`Estimated cost: $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
}else{
costLine.style.display='none';
costLine.textContent='';
}
}
} }
function scrollIfPinned(){ function scrollIfPinned(){
@@ -257,45 +389,41 @@ function renderMd(raw){
} }
function setStatus(t){ function setStatus(t){
const bar=$('activityBar'); if(!t)return;
const txt=$('activityText'); showToast(t, 4000);
const dismiss=$('btnDismissStatus');
if(!bar||!txt)return;
if(!t){
bar.style.display='none';
txt.textContent='';
if(dismiss)dismiss.style.display='none';
} else {
txt.textContent=t;
bar.style.display='';
// Show dismiss X only for static/error messages, not transient busy ones
const transient = t.endsWith('…') || t === (window._botName||'Hermes')+' is thinking\u2026';
if(dismiss)dismiss.style.display=(!transient && !S.busy)?'inline':'none';
}
} }
function setComposerStatus(t){
const el=$('composerStatus');
if(!el)return;
if(!t){
el.style.display='none';
el.textContent='';
return;
}
el.textContent=t;
el.style.display='';
}
function updateSendBtn(){ function updateSendBtn(){
const btn=$('btnSend'); const btn=$('btnSend');
if(!btn) return; if(!btn) return;
const hasContent=$('msg').value.trim().length>0||S.pendingFiles.length>0; const hasContent=$('msg').value.trim().length>0||S.pendingFiles.length>0;
const shouldShow=hasContent&&!S.busy; const canSend=hasContent&&!S.busy;
if(shouldShow&&btn.style.display==='none'){ // Hide while busy (cancel button takes its place); show otherwise
btn.style.display=''; btn.style.display=S.busy?'none':'';
// Remove then re-add class to retrigger animation each time btn.disabled=!canSend;
if(canSend&&!btn.classList.contains('visible')){
btn.classList.remove('visible'); btn.classList.remove('visible');
requestAnimationFrame(()=>btn.classList.add('visible')); requestAnimationFrame(()=>btn.classList.add('visible'));
} else if(!shouldShow&&btn.style.display!=='none'){
btn.style.display='none';
btn.classList.remove('visible');
} }
} }
function setBusy(v){ function setBusy(v){
S.busy=v; S.busy=v;
$('btnSend').disabled=v;
updateSendBtn(); updateSendBtn();
const dots=$('activityDots');
if(dots) dots.style.display=v?'flex':'none';
if(!v){ if(!v){
setStatus(''); setStatus('');
setComposerStatus('');
// Always hide Cancel button when not busy // Always hide Cancel button when not busy
const _cb=$('btnCancel');if(_cb)_cb.style.display='none'; const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
updateQueueBadge(); updateQueueBadge();
@@ -471,7 +599,7 @@ function copyMsg(btn){
const text=row?row.dataset.rawText:''; const text=row?row.dataset.rawText:'';
if(!text)return; if(!text)return;
navigator.clipboard.writeText(text).then(()=>{ navigator.clipboard.writeText(text).then(()=>{
const orig=btn.innerHTML;btn.innerHTML='&#10003;';btn.style.color='var(--blue)'; const orig=btn.innerHTML;btn.innerHTML=li('check',13);btn.style.color='var(--blue)';
setTimeout(()=>{btn.innerHTML=orig;btn.style.color='';},1500); setTimeout(()=>{btn.innerHTML=orig;btn.style.color='';},1500);
}).catch(()=>showToast('Copy failed')); }).catch(()=>showToast('Copy failed'));
} }
@@ -577,10 +705,14 @@ async function checkInflightOnBoot(sid) {
function syncTopbar(){ function syncTopbar(){
if(!S.session){ if(!S.session){
document.title=window._botName||'Hermes'; document.title=window._botName||'Hermes';
// Show default workspace name even without a session if(typeof syncWorkspaceDisplays==='function') syncWorkspaceDisplays();
const sidebarName=$('sidebarWsName'); if(typeof syncModelChip==='function') syncModelChip();
if(sidebarName && sidebarName.textContent==='Workspace'){ if(typeof _syncHermesPanelSessionActions==='function') _syncHermesPanelSessionActions();
sidebarName.textContent=t('no_workspace'); else {
const sidebarName=$('sidebarWsName');
if(sidebarName && sidebarName.textContent==='Workspace'){
sidebarName.textContent=t('no_workspace');
}
} }
return; return;
} }
@@ -592,41 +724,33 @@ function syncTopbar(){
// If a profile switch just happened, apply its model rather than the session's stale value. // If a profile switch just happened, apply its model rather than the session's stale value.
// S._pendingProfileModel is set by switchToProfile() and cleared here after one application. // S._pendingProfileModel is set by switchToProfile() and cleared here after one application.
const modelOverride=S._pendingProfileModel; const modelOverride=S._pendingProfileModel;
let currentModel=S.session.model||'';
if(modelOverride){ if(modelOverride){
S._pendingProfileModel=null; S._pendingProfileModel=null;
_applyModelToDropdown(modelOverride,$('modelSelect')); _applyModelToDropdown(modelOverride,$('modelSelect'));
currentModel=modelOverride;
} else { } else {
const m=S.session.model||''; const applied=_applyModelToDropdown(currentModel,$('modelSelect'));
const applied=_applyModelToDropdown(m,$('modelSelect'));
// If the model isn't in the current provider list, add it as a visually marked // If the model isn't in the current provider list, add it as a visually marked
// "(unavailable)" entry so the session value is preserved without misleading the user. // "(unavailable)" entry so the session value is preserved without misleading the user.
// Selecting it will still attempt to send (same as before), but the label makes // Selecting it will still attempt to send (same as before), but the label makes
// clear it's a stale model from a previous session. // clear it's a stale model from a previous session.
if(!applied && m){ if(!applied && currentModel){
const opt=document.createElement('option'); const opt=document.createElement('option');
opt.value=m; opt.value=currentModel;
opt.textContent=getModelLabel(m)+t('model_unavailable'); opt.textContent=getModelLabel(currentModel)+t('model_unavailable');
opt.style.color='var(--muted, #888)'; opt.style.color='var(--muted, #888)';
opt.title=t('model_unavailable_title'); opt.title=t('model_unavailable_title');
$('modelSelect').appendChild(opt); $('modelSelect').appendChild(opt);
$('modelSelect').value=m; $('modelSelect').value=currentModel;
} }
} }
if(typeof syncModelChip==='function') syncModelChip();
// Show Clear button only when session has messages // Show Clear button only when session has messages
const clearBtn=$('btnClearConv'); const clearBtn=$('btnClearConv');
if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none'; if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none';
const displayModel=$('modelSelect').value||m; if(typeof _syncHermesPanelSessionActions==='function') _syncHermesPanelSessionActions();
$('modelChip').textContent=getModelLabel(displayModel); if(typeof syncWorkspaceDisplays==='function') syncWorkspaceDisplays();
const ws=S.session.workspace||'';
// Update sidebar workspace display
const sidebarName=$('sidebarWsName');
const sidebarPath=$('sidebarWsPath');
if(sidebarName){
sidebarName.textContent=getWorkspaceFriendlyName(ws);
}
if(sidebarPath){
sidebarPath.textContent=ws;
}
// modelSelect already set above // modelSelect already set above
// Update profile chip label // Update profile chip label
const profileLabel=$('profileChipLabel'); const profileLabel=$('profileChipLabel');
@@ -698,22 +822,22 @@ function renderMessages(){
// Render thinking card before the assistant message (collapsed by default) // Render thinking card before the assistant message (collapsed by default)
if(thinkingText&&!isUser){ if(thinkingText&&!isUser){
const thinkRow=document.createElement('div');thinkRow.className='msg-row thinking-card-row'; const thinkRow=document.createElement('div');thinkRow.className='msg-row thinking-card-row';
thinkRow.innerHTML=`<div class="thinking-card"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">&#128161;</span><span class="thinking-card-label">${t('thinking')}</span><span class="thinking-card-toggle">&#9656;</span></div><div class="thinking-card-body"><pre>${esc(thinkingText)}</pre></div></div>`; thinkRow.innerHTML=`<div class="thinking-card"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span><span class="thinking-card-toggle">${li('chevron-right',12)}</span></div><div class="thinking-card-body"><pre>${esc(thinkingText)}</pre></div></div>`;
inner.appendChild(thinkRow); inner.appendChild(thinkRow);
} }
const row=document.createElement('div');row.className='msg-row'; const row=document.createElement('div');row.className='msg-row';
row.dataset.msgIdx=rawIdx;row.dataset.role=m.role||'assistant'; row.dataset.msgIdx=rawIdx;row.dataset.role=m.role||'assistant';
let filesHtml=''; let filesHtml='';
if(m.attachments&&m.attachments.length) if(m.attachments&&m.attachments.length)
filesHtml=`<div class="msg-files">${m.attachments.map(f=>`<div class="msg-file-badge">&#128206; ${esc(f)}</div>`).join('')}</div>`; filesHtml=`<div class="msg-files">${m.attachments.map(f=>`<div class="msg-file-badge">${li('paperclip',12)} ${esc(f)}</div>`).join('')}</div>`;
const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'<br>') : renderMd(String(content)); const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'<br>') : renderMd(String(content));
// Action buttons for this bubble // Action buttons for this bubble
const editBtn = isUser ? `<button class="msg-action-btn" title="${t('edit_message')}" onclick="editMessage(this)">&#9998;</button>` : ''; const editBtn = isUser ? `<button class="msg-action-btn" title="${t('edit_message')}" onclick="editMessage(this)">${li('pencil',13)}</button>` : '';
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('regenerate')}" onclick="regenerateResponse(this)">&#8635;</button>` : ''; const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('regenerate')}" onclick="regenerateResponse(this)">${li('rotate-ccw',13)}</button>` : '';
const tsVal=m._ts||m.timestamp; const tsVal=m._ts||m.timestamp;
const tsTitle=tsVal?new Date(tsVal*1000).toLocaleString():''; const tsTitle=tsVal?new Date(tsVal*1000).toLocaleString():'';
const _bn=window._botName||'Hermes'; const _bn=window._botName||'Hermes';
row.innerHTML=`<div class="msg-role ${m.role}" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon ${m.role}">${isUser?'Y':esc(_bn.charAt(0).toUpperCase())}</div><span style="font-size:12px">${isUser?t('you'):esc(_bn)}</span>${tsTitle?`<span class="msg-time">${new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span>`:''}<span class="msg-actions">${editBtn}<button class="msg-copy-btn msg-action-btn" title="${t('copy')}" onclick="copyMsg(this)">&#128203;</button>${retryBtn}</span></div>${filesHtml}<div class="msg-body">${bodyHtml}</div>`; row.innerHTML=`<div class="msg-role ${m.role}" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon ${m.role}">${isUser?'Y':esc(_bn.charAt(0).toUpperCase())}</div><span style="font-size:12px">${isUser?t('you'):esc(_bn)}</span>${tsTitle?`<span class="msg-time">${new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span>`:''}<span class="msg-actions">${editBtn}<button class="msg-copy-btn msg-action-btn" title="${t('copy')}" onclick="copyMsg(this)">${li('copy',13)}</button>${retryBtn}</span></div>${filesHtml}<div class="msg-body">${bodyHtml}</div>`;
row.dataset.rawText = String(content).trim(); row.dataset.rawText = String(content).trim();
inner.appendChild(row); inner.appendChild(row);
} }
@@ -880,12 +1004,12 @@ function buildToolCard(tc){
const isSubagent=tc.name==='subagent_progress'; const isSubagent=tc.name==='subagent_progress';
const isDelegation=tc.name==='delegate_task'; const isDelegation=tc.name==='delegate_task';
const cardClass='tool-card'+(tc.done===false?' tool-card-running':'')+(isSubagent?' tool-card-subagent':''); const cardClass='tool-card'+(tc.done===false?' tool-card-running':'')+(isSubagent?' tool-card-subagent':'');
// Clean up subagent preview: strip leading 🔀 emoji since the icon already shows it // Clean up legacy subagent prefixes since the Lucide icon already shows it
let displayName=tc.name; let displayName=tc.name;
if(isSubagent) displayName='Subagent'; if(isSubagent) displayName='Subagent';
if(isDelegation) displayName='Delegate task'; if(isDelegation) displayName='Delegate task';
let previewText=tc.preview||displaySnippet||''; let previewText=tc.preview||displaySnippet||'';
if(isSubagent) previewText=previewText.replace(/^🔀\s*/,''); if(isSubagent) previewText=previewText.replace(/^(?:\u{1F500}|↳)\s*/u,'');
row.innerHTML=` row.innerHTML=`
<div class="${cardClass}"> <div class="${cardClass}">
<div class="tool-card-header" onclick="this.closest('.tool-card').classList.toggle('open')"> <div class="tool-card-header" onclick="this.closest('.tool-card').classList.toggle('open')">
@@ -1338,7 +1462,7 @@ function renderTray(){
updateSendBtn(); updateSendBtn();
S.pendingFiles.forEach((f,i)=>{ S.pendingFiles.forEach((f,i)=>{
const chip=document.createElement('div');chip.className='attach-chip'; const chip=document.createElement('div');chip.className='attach-chip';
chip.innerHTML=`&#128206; ${esc(f.name)} <button title="${t('remove_title')}">&#10005;</button>`; chip.innerHTML=`${li('paperclip',12)} ${esc(f.name)} <button title="${t('remove_title')}">${li('x',12)}</button>`;
chip.querySelector('button').onclick=()=>{S.pendingFiles.splice(i,1);renderTray();}; chip.querySelector('button').onclick=()=>{S.pendingFiles.splice(i,1);renderTray();};
tray.appendChild(chip); tray.appendChild(chip);
}); });

View File

@@ -172,7 +172,7 @@ def pytest_collection_modifyitems(config, items):
skipped += 1 skipped += 1
if skipped: if skipped:
print(f"\n⚠️ hermes-agent not found {skipped} agent-dependent tests will be skipped\n") print(f"\nWARNING: hermes-agent not found; {skipped} agent-dependent tests will be skipped\n")
# ── Helpers ────────────────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────────────────

View File

@@ -70,11 +70,11 @@ def test_mobile_bottom_nav_present():
def test_mobile_files_button_present(): def test_mobile_files_button_present():
"""Mobile files toggle button (#btnMobileFiles) must be in HTML and CSS.""" """Mobile files toggle button (#btnWorkspacePanelToggle.workspace-toggle-btn) must be in HTML and CSS."""
assert 'id="btnMobileFiles"' in HTML, \ assert 'id="btnWorkspacePanelToggle"' in HTML, \
"#btnMobileFiles missing from index.html" "#btnWorkspacePanelToggle missing from index.html"
assert "mobile-files-btn" in CSS, \ assert "workspace-toggle-btn" in CSS, \
".mobile-files-btn CSS missing from style.css" ".workspace-toggle-btn CSS missing from style.css"
# ── Profile dropdown overflow ───────────────────────────────────────────────── # ── Profile dropdown overflow ─────────────────────────────────────────────────
@@ -115,13 +115,13 @@ def test_topbar_chips_mobile_overflow():
def test_workspace_close_button_present(): def test_workspace_close_button_present():
"""Workspace panel must have a close/hide button accessible on mobile.""" """Workspace panel must have a close/hide button accessible on mobile."""
# Either a dedicated mobile close button or the X button that closes the panel # Either a dedicated mobile close button or the toggle button that closes the panel
has_close = ( has_close = (
'onclick="toggleMobileFiles()"' in HTML or 'onclick="closeWorkspacePanel()"' in HTML or
'toggleMobileFiles' in HTML 'onclick="toggleWorkspacePanel()"' in HTML
) )
assert has_close, \ assert has_close, \
"toggleMobileFiles() must be wired to a button to close the workspace panel on mobile" "closeWorkspacePanel() or toggleWorkspacePanel() must be wired to a button to close the workspace panel on mobile"
def test_toggle_mobile_files_js_defined(): def test_toggle_mobile_files_js_defined():

View File

@@ -226,8 +226,8 @@ def test_loadSession_resets_busy_state_for_idle_session(cleanup_test_sessions):
src = (REPO_ROOT / "static/sessions.js").read_text() src = (REPO_ROOT / "static/sessions.js").read_text()
# The fix adds explicit S.busy=false in the non-inflight else branch # The fix adds explicit S.busy=false in the non-inflight else branch
assert "S.busy=false;" in src, "sessions.js loadSession must set S.busy=false when loading a non-inflight session" assert "S.busy=false;" in src, "sessions.js loadSession must set S.busy=false when loading a non-inflight session"
# btnSend must be explicitly re-enabled # btnSend state must be refreshed via updateSendBtn
assert "$('btnSend').disabled=false;" in src, "sessions.js loadSession must enable btnSend for non-inflight sessions" assert "updateSendBtn()" in src, "sessions.js loadSession must call updateSendBtn for non-inflight sessions"
def test_done_handler_guards_setbusy_with_inflight_check(cleanup_test_sessions): def test_done_handler_guards_setbusy_with_inflight_check(cleanup_test_sessions):
@@ -352,12 +352,12 @@ def test_respond_approval_uses_approval_session_id(cleanup_test_sessions):
assert "_approvalSessionId" in fn_body, "respondApproval must read _approvalSessionId, not S.session.session_id" assert "_approvalSessionId" in fn_body, "respondApproval must read _approvalSessionId, not S.session.session_id"
# ── R11: Activity bar shows cross-session tool status ───────────────────── # ── R11: Tool progress must not use shared status chrome ──────────────────
def test_tool_status_only_shown_for_current_session(cleanup_test_sessions): def test_tool_status_only_shown_for_current_session(cleanup_test_sessions):
"""R11: The activity bar setStatus() call in the tool SSE handler must only """R11: Tool progress should not drive the global status bar or composer
fire when the user is viewing the session that triggered the tool. status. Live tool cards in the current conversation are the authoritative
When missing, session A's tool names would appear in session B's activity bar. progress UI, which avoids cross-session status leakage entirely.
""" """
src = (REPO_ROOT / "static/messages.js").read_text() src = (REPO_ROOT / "static/messages.js").read_text()
# Sprint 12: handler moved into _wireSSE(source) # Sprint 12: handler moved into _wireSSE(source)
@@ -366,14 +366,10 @@ def test_tool_status_only_shown_for_current_session(cleanup_test_sessions):
tool_idx = src.find("es.addEventListener('tool'") tool_idx = src.find("es.addEventListener('tool'")
assert tool_idx >= 0 assert tool_idx >= 0
tool_block = src[tool_idx:tool_idx+400] tool_block = src[tool_idx:tool_idx+400]
# setStatus must be inside the activeSid guard, not before it assert "setStatus(" not in tool_block, \
status_pos = tool_block.find("setStatus(") "tool handler should not use the global activity/status bar"
guard_pos = tool_block.find("S.session.session_id===activeSid") assert "setComposerStatus(" not in tool_block, \
assert guard_pos >= 0, "tool handler must guard with activeSid check" "tool handler should not use composer status for tool progress"
# The guard must appear BEFORE or AROUND the setStatus call
# (status only fires for the current session)
assert status_pos > tool_block.find("activeSid"), \
"setStatus in tool handler must be inside the activeSid guard"
# ── R12: Live tool cards lost on switch-away and switch-back ────────────── # ── R12: Live tool cards lost on switch-away and switch-back ──────────────

View File

@@ -1,6 +1,6 @@
""" """
Sprint 16 Tests: safe HTML rendering in renderMd(), active session styling, Sprint 16 Tests: safe HTML rendering in renderMd(), active session styling,
session sidebar polish (SVG icons, overlay actions). session sidebar polish (SVG icons, dropdown actions).
""" """
import html as _html import html as _html
import pathlib import pathlib
@@ -676,20 +676,22 @@ def test_sessions_js_has_svg_icons(cleanup_test_sessions):
assert "<svg" in code, "SVG content not found in ICONS" assert "<svg" in code, "SVG content not found in ICONS"
def test_sessions_js_has_overlay_actions(cleanup_test_sessions): def test_sessions_js_has_dropdown_actions(cleanup_test_sessions):
"""sessions.js must use .session-actions overlay div for action buttons.""" """sessions.js must use a single trigger button and dropdown for session actions."""
src = REPO_ROOT / "static" / "sessions.js" src = REPO_ROOT / "static" / "sessions.js"
code = src.read_text() code = src.read_text()
assert "session-actions" in code, ".session-actions overlay not found in sessions.js" assert "session-actions-trigger" in code, "session action trigger button not found in sessions.js"
assert "session-action-menu" in code, "session action dropdown menu not found in sessions.js"
def test_style_css_has_session_actions_overlay(cleanup_test_sessions): def test_style_css_has_session_actions_dropdown(cleanup_test_sessions):
"""style.css must define .session-actions with position:absolute.""" """style.css must define trigger and dropdown styles for session actions."""
src = REPO_ROOT / "static" / "style.css" src = REPO_ROOT / "static" / "style.css"
code = src.read_text() code = src.read_text()
assert ".session-actions" in code, ".session-actions not found in style.css" assert ".session-actions" in code, ".session-actions not found in style.css"
assert "position:absolute" in code or "position: absolute" in code, \ assert ".session-action-menu" in code, ".session-action-menu not found in style.css"
".session-actions must use position:absolute for overlay" assert "position:fixed" in code or "position: fixed" in code, \
".session-action-menu must use position:fixed to avoid sidebar clipping"
def test_style_css_active_session_uses_gold(cleanup_test_sessions): def test_style_css_active_session_uses_gold(cleanup_test_sessions):

View File

@@ -23,12 +23,12 @@ def test_send_button_present():
assert 'id="btnSend"' in html assert 'id="btnSend"' in html
def test_send_button_hidden_by_default(): def test_send_button_disabled_by_default():
"""btnSend must start hidden (display:none) — only shown when there is content.""" """btnSend must start disabled — enabled only when there is content."""
html, _ = get_text("/") html, _ = get_text("/")
btn_match = re.search(r'id="btnSend"[^>]*>', html) btn_match = re.search(r'id="btnSend"[^>]*>', html)
assert btn_match, "btnSend element not found" assert btn_match, "btnSend element not found"
assert 'display:none' in btn_match.group(0) assert 'disabled' in btn_match.group(0)
def test_send_button_no_text_label(): def test_send_button_no_text_label():
@@ -264,14 +264,13 @@ def test_update_send_btn_uses_visible_class():
assert 'visible' in fn_body assert 'visible' in fn_body
def test_update_send_btn_uses_display_none(): def test_update_send_btn_uses_disabled():
"""updateSendBtn must hide the button with display:none when no content.""" """updateSendBtn must disable the button when no content or busy."""
js, _ = get_text("/static/ui.js") js, _ = get_text("/static/ui.js")
fn_idx = js.find('function updateSendBtn') fn_idx = js.find('function updateSendBtn')
fn_end = js.find('\n}', fn_idx) + 2 fn_end = js.find('\n}', fn_idx) + 2
fn_body = js[fn_idx:fn_end] fn_body = js[fn_idx:fn_end]
assert 'display' in fn_body assert 'disabled' in fn_body
assert 'none' in fn_body
def test_set_busy_calls_update_send_btn(): def test_set_busy_calls_update_send_btn():
@@ -321,14 +320,13 @@ def test_send_button_still_has_send_btn_class():
assert 'class="send-btn"' in html assert 'class="send-btn"' in html
def test_ui_js_set_busy_still_disables_btn(): def test_ui_js_set_busy_calls_update_send_btn():
"""setBusy must still set btnSend.disabled (not just hide it).""" """setBusy must call updateSendBtn to manage button disabled state."""
js, _ = get_text("/static/ui.js") js, _ = get_text("/static/ui.js")
busy_idx = js.find('function setBusy') busy_idx = js.find('function setBusy')
busy_end = js.find('\n}', busy_idx) + 2 busy_end = js.find('\n}', busy_idx) + 2
busy_body = js[busy_idx:busy_end] busy_body = js[busy_idx:busy_end]
assert "btnSend" in busy_body assert 'updateSendBtn' in busy_body
assert 'disabled' in busy_body
def test_index_html_attach_button_unchanged(): def test_index_html_attach_button_unchanged():

View File

@@ -226,3 +226,19 @@ class TestOnboardingStatusApiOAuth:
assert data["system"]["setup_state"] in valid, ( assert data["system"]["setup_state"] in valid, (
f"Unexpected setup_state: {data['system']['setup_state']!r}" f"Unexpected setup_state: {data['system']['setup_state']!r}"
) )
# ── Control Center: section reset on close ─────────────────────────────────
def test_control_center_resets_active_section_on_close():
"""Closing the control center must reset _settingsSection to 'conversation'."""
src = open('static/panels.js').read()
assert '_settingsSection' in src, '_settingsSection state variable missing from panels.js'
assert "_settingsSection = 'conversation'" in src or "_settingsSection='conversation'" in src, \
'Control center does not reset section to conversation on close'
def test_control_center_tab_highlight_on_open():
"""Opening the control center must use settings-tabs for section navigation."""
css = open('static/style.css').read()
assert 'settings-tabs' in css, 'settings-tabs CSS class for control center tabs missing from style.css'

View File

@@ -36,6 +36,7 @@ def test_index_html_served():
assert status == 200 assert status == 200
assert b"sidebarResize" in raw, "Resize handle not found in HTML" assert b"sidebarResize" in raw, "Resize handle not found in HTML"
assert b"cronCreateForm" in raw, "Cron create form not found in HTML" assert b"cronCreateForm" in raw, "Cron create form not found in HTML"
assert b"btnHermesPanel" in raw, "Hermes control center trigger not found in HTML"
assert b"btnExportJSON" in raw, "Export JSON button not found in HTML" assert b"btnExportJSON" in raw, "Export JSON button not found in HTML"
def test_index_html_file_exists(): def test_index_html_file_exists():