From a4e2174c29b34a95f7123b732634afa696e60564 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Mon, 30 Mar 2026 20:40:19 -0700 Subject: [PATCH] =?UTF-8?q?Hermes=20WebUI=20v0.1.0=20=E2=80=94=20initial?= =?UTF-8?q?=20public=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 28 + .gitignore | 26 + AGENTS.md | 53 ++ ARCHITECTURE.md | 1588 ++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 312 ++++++++ LICENSE | 21 + README.md | 213 +++++ ROADMAP.md | 309 +++++++ SPRINTS.md | 407 ++++++++++ TESTING.md | 1600 +++++++++++++++++++++++++++++++++++++ api/__init__.py | 1 + api/config.py | 273 +++++++ api/helpers.py | 57 ++ api/models.py | 114 +++ api/streaming.py | 218 +++++ api/upload.py | 77 ++ api/workspace.py | 77 ++ requirements.txt | 4 + server.py | 704 ++++++++++++++++ start.sh | 260 ++++++ static/boot.js | 152 ++++ static/index.html | 264 ++++++ static/messages.js | 310 +++++++ static/panels.js | 600 ++++++++++++++ static/sessions.js | 206 +++++ static/style.css | 450 +++++++++++ static/ui.js | 589 ++++++++++++++ static/workspace.js | 168 ++++ tests/__init__.py | 0 tests/conftest.py | 240 ++++++ tests/test_regressions.py | 416 ++++++++++ tests/test_sprint1.py | 437 ++++++++++ tests/test_sprint10.py | 139 ++++ tests/test_sprint2.py | 106 +++ tests/test_sprint3.py | 144 ++++ tests/test_sprint4.py | 156 ++++ tests/test_sprint5.py | 140 ++++ tests/test_sprint6.py | 151 ++++ tests/test_sprint7.py | 130 +++ tests/test_sprint8.py | 125 +++ tests/test_sprint9.py | 115 +++ 41 files changed, 11380 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 ARCHITECTURE.md create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 SPRINTS.md create mode 100644 TESTING.md create mode 100644 api/__init__.py create mode 100644 api/config.py create mode 100644 api/helpers.py create mode 100644 api/models.py create mode 100644 api/streaming.py create mode 100644 api/upload.py create mode 100644 api/workspace.py create mode 100644 requirements.txt create mode 100644 server.py create mode 100755 start.sh create mode 100644 static/boot.js create mode 100644 static/index.html create mode 100644 static/messages.js create mode 100644 static/panels.js create mode 100644 static/sessions.js create mode 100644 static/style.css create mode 100644 static/ui.js create mode 100644 static/workspace.js create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_regressions.py create mode 100644 tests/test_sprint1.py create mode 100644 tests/test_sprint10.py create mode 100644 tests/test_sprint2.py create mode 100644 tests/test_sprint3.py create mode 100644 tests/test_sprint4.py create mode 100644 tests/test_sprint5.py create mode 100644 tests/test_sprint6.py create mode 100644 tests/test_sprint7.py create mode 100644 tests/test_sprint8.py create mode 100644 tests/test_sprint9.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c5381d2 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Hermes Web UI -- local machine config template +# Copy this to .env and fill in your values. +# start.sh sources .env automatically if present. +# All values are optional -- auto-discovery will fill in anything left blank. + +# Path to your hermes-agent checkout (the repo that contains run_agent.py) +# HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent + +# Python executable to use (defaults to the agent venv if found) +# HERMES_WEBUI_PYTHON=/path/to/python + +# Bind address (default: 127.0.0.1 -- loopback only, safe default) +# HERMES_WEBUI_HOST=127.0.0.1 + +# Port to listen on (default: 8787) +# HERMES_WEBUI_PORT=8787 + +# Where to store sessions, workspaces, and other state (default: ~/.hermes/webui-mvp) +# HERMES_WEBUI_STATE_DIR=~/.hermes/webui-mvp + +# Default workspace directory shown on first launch +# HERMES_WEBUI_DEFAULT_WORKSPACE=~/workspace + +# Base directory for all Hermes state (affects all paths above if set) +# HERMES_HOME=~/.hermes + +# Path to your Hermes config.yaml (for toolsets and model config) +# HERMES_CONFIG_PATH=~/.hermes/config.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01e44a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Python cache +__pycache__/ +*.pyc +*.pyo +*.pyd + +# Backup and temporary files +*.bak +*.swp +*.swo + +# Archive directory (pre-git backups, kept on disk but not tracked) +archive/ + +# Local environment and secrets (but keep the example template) +.env +.env.* +!.env.example + +# Generated screenshots and transient artifacts +screenshot-*.png +full-UI.png + +# OS files +.DS_Store +Thumbs.db diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dd92404 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,53 @@ +# Web UI MVP Instructions + +Canonical source: / +Symlink (for imports): /webui-mvp -> +Runtime state: ~/.hermes/webui-mvp/sessions/ + +Purpose: +- Claude-style web UI for Hermes. Chat, workspace file browser, cron/skills/memory viewers. + +Start server: + cd + nohup venv/bin/python /server.py > /tmp/webui-mvp.log 2>&1 & + # OR: /start.sh + +Run tests: + cd + venv/bin/python -m pytest /tests/ -v + +Health check: curl http://127.0.0.1:8787/health +Logs: tail -f /tmp/webui-mvp.log +SSH tunnel from Mac: ssh -N -L 8787:127.0.0.1:8787 @ + +Living documents (always update after a sprint): + /ROADMAP.md + /ARCHITECTURE.md + /TESTING.md + +Sprint process skill: webui-sprint-loop + +# Workspace Convention (Web UI Sessions) + +When running as an agent invoked from the web UI, each user message is prefixed with: + + [Workspace: /absolute/path/to/workspace] + +This tag is the single authoritative source of the active workspace. It reflects +whichever workspace the user has selected in the UI at the moment they sent that message. +It updates on every message, so if the user switches workspaces mid-session, the very +next message will carry the new path. Always use the value from the most recent tag. + +This tag overrides any prior workspace mentioned in the system prompt, memory, or +conversation history. Never infer or fall back to a hardcoded path like +~/workspace when this tag is present. + +Apply it as the default working directory for ALL file operations: + + - write_file: resolve relative paths against this workspace + - read_file / search_files: resolve paths relative to this workspace + - terminal workdir: set to this path unless the user explicitly says otherwise + - patch: resolve file paths relative to this workspace + +If no [Workspace: ...] tag is present (e.g., CLI sessions), fall back to +~/workspace as the default. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..bdaafba --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,1588 @@ +# Hermes Web UI: Developer and Architecture Guide + +> This document is the canonical reference for anyone (human or agent) working on the +> Hermes Web UI. It covers the exact current state of the code, every design decision and +> quirk discovered during development, and a phased architecture improvement roadmap that +> runs in parallel with the feature roadmap in ROADMAP.md. +> +> Keep this document updated as architecture changes are made. + +--- + +## 1. Overview and Purpose + +The Hermes Web UI is a lightweight, single-file web application that gives you +a browser-based interface to the Hermes agent that is functionally equivalent to the CLI. +It is modeled on the Claude interface: a three-panel layout with a sidebar for +session management, a central chat area, and a right panel for workspace file browsing. + +The design philosophy is deliberately minimal. There is no build step, no bundler, no +frontend framework. Everything ships from a single Python file. This makes the code easy +to modify from a terminal or by an agent, but it creates architectural debt that grows as +the feature set expands. + +--- + +## 2. File Inventory + + /webui-mvp/ + server.py Main server file. ~1150 lines. Pure Python. + HTTP server, all API handlers, Session model, SSE engine, + approval wiring, file upload parser. No inline HTML/CSS/JS. + (Phase A+E complete: HTML/CSS/JS all extracted to static/) + server.py.bak Backup from a prior iteration. Kept for reference. + server_new.py Intermediate ~900-line draft. Superseded by server.py. + Safe to delete once Wave 1 begins. + start.sh Convenience script: kills running instance, starts server.py + via nohup, writes stdout/stderr to /tmp/webui-mvp.log + AGENTS.md Instruction file for agents working in this directory. + ROADMAP.md Feature and product roadmap document. + ARCHITECTURE.md THIS FILE. + +State directory (runtime data, separate from source): + + ~/.hermes/webui-mvp/ + sessions/ One JSON file per session: {session_id}.json + test-workspace/ Default empty workspace used during development + +Log file: + + /tmp/webui-mvp.log stdout/stderr from the background server process + +--- + +## 3. Runtime Environment + +- Python interpreter: /venv/bin/python +- The venv has all Hermes agent dependencies (run_agent, tools/*, cron/*) +- Server binds to 127.0.0.1:8787 (localhost only, not public internet) +- Access from Mac: SSH tunnel: ssh -N -L 8787:127.0.0.1:8787 @ +- The server imports Hermes modules via sys.path.insert(0, parent_dir) + +Environment variables controlling behavior: + + HERMES_WEBUI_HOST Bind address (default: 127.0.0.1) + HERMES_WEBUI_PORT Port (default: 8787) + HERMES_WEBUI_DEFAULT_WORKSPACE Default workspace path for new sessions + HERMES_WEBUI_STATE_DIR Where sessions/ folder lives + HERMES_CONFIG_PATH Path to ~/.hermes/config.yaml + HERMES_WEBUI_DEFAULT_MODEL Default LLM model string + +Test isolation environment variables (set by conftest.py): + + HERMES_WEBUI_PORT=8788 Isolated test port + HERMES_WEBUI_STATE_DIR=~/.hermes/webui-mvp-test Isolated test state + HERMES_WEBUI_DEFAULT_WORKSPACE=.../test-workspace Isolated test workspace + +Tests NEVER talk to the production server (port 8787). +The test state dir is wiped before each test session and deleted after. +See: /tests/conftest.py + +Per-request environment variables (set by chat handler, restored after): + + TERMINAL_CWD Set to session.workspace before running agent. + The terminal tool reads this to default cwd. + HERMES_EXEC_ASK Set to "1" to enable approval gate for dangerous commands. + HERMES_SESSION_KEY Set to session_id. The approval tool keys pending entries + by this value, enabling per-session approval state. + +WARNING: These env vars are process-global. Two concurrent chat requests will clobber +each other. This is safe only for single-user, single-concurrent-request use. +See Architecture Phase B for the fix. + +--- + +## 4. Server Architecture: Current State + +### 4.1 HTTP Server Layer + +Python stdlib ThreadingHTTPServer (from http.server). Each HTTP request runs in its own +thread. The Handler class subclasses BaseHTTPRequestHandler with two methods: + + do_GET Routes: /, /health, /api/session, /api/sessions, /api/list, + /api/chat/stream, /api/file, /api/approval/pending + do_POST Routes: /api/upload, /api/session/new, /api/session/update, + /api/session/delete, /api/chat/start, /api/chat, + /api/approval/respond + +Routing is a flat if/elif chain inside each method. No routing framework. + +Helper functions used by all handlers: + + j(handler, payload, status=200) Sends JSON response with correct headers + t(handler, payload, status=200, ct) Sends plain text or HTML response + read_body(handler) Reads and JSON-parses the POST body + +CRITICAL ORDERING RULE in do_POST: +The /api/upload check MUST appear BEFORE calling read_body(). read_body() calls +handler.rfile.read() which consumes the HTTP body stream. The upload handler also +needs rfile (to read the multipart payload). If read_body() runs first on a multipart +request, the upload handler receives an empty body and the upload silently fails. + +### 4.2 Session Model + +Session is a plain Python class (not a dataclass, not SQLAlchemy): + + Fields: + session_id hex string, 12 chars (uuid4().hex[:12]) + title string, auto-set from first user message + workspace absolute path string, resolved at creation + model OpenRouter model ID string (e.g. "anthropic/claude-sonnet-4.6") + messages list of OpenAI-format message dicts + created_at float Unix timestamp + updated_at float Unix timestamp, updated on every save() + + Key methods: + path (property) Returns SESSION_DIR/{session_id}.json + save() Writes __dict__ as pretty JSON to path, updates updated_at + load(cls, sid) Class method: reads JSON from disk, returns Session or None + compact() Returns metadata-only dict (no messages) for the session list + + In-memory cache: + SESSIONS = {} dict: session_id -> Session object + LOCK = threading.Lock() defined but NOT currently used around SESSIONS access + + get_session(sid): checks SESSIONS cache, loads from disk on miss, raises KeyError + new_session(workspace, model): creates Session, caches in SESSIONS, saves, returns + all_sessions(): scans SESSION_DIR/*.json + SESSIONS, deduplicates, sorts by updated_at, + returns list of compact() dicts + + all_sessions() does a full directory scan on every call. + With 10 sessions: negligible. With 1000+: will be slow. + See Architecture Phase C for the index file fix. + +title_from(): takes messages list, finds first user message, returns first 64 chars. +Called after run_conversation() completes to set the session title retroactively. + +### 4.3 SSE Streaming Engine + +This is the most architecturally interesting part. Two endpoints cooperate: + + POST /api/chat/start Receives the user message. Creates a queue.Queue, stores it + in STREAMS[stream_id], spawns a daemon thread running + _run_agent_streaming(), returns {stream_id} immediately. + + GET /api/chat/stream Long-lived SSE connection. Reads from STREAMS[stream_id] + and forwards events to the browser until 'done' or 'error'. + +Queue registry: + + STREAMS = {} dict: stream_id -> queue.Queue + STREAMS_LOCK = threading.Lock() + +SSE event types and their data shapes: + + token {"text": "..."} LLM token delta + tool {"name": "...", "preview": "..."} Tool invocation started + approval {"command": "...", "description": "...", "pattern_keys": [...]} + done {"session": {compact_fields + messages}} Agent finished successfully + error {"message": "...", "trace": "..."} Agent threw exception + +The SSE handler loop: + - Blocks on queue.get(timeout=30) + - On timeout (no events in 30s): sends a heartbeat comment (": heartbeat + +") + to keep the connection alive through proxies and firewalls + - On 'done' or 'error' event: breaks the loop and returns + - Catches BrokenPipeError and ConnectionResetError silently (browser disconnected) + +Stream cleanup: _run_agent_streaming() pops its stream_id from STREAMS in a finally +block. If the browser disconnects mid-stream, the daemon thread runs to completion and +then cleans up. The queue fills and the put_nowait() calls fail silently (queue.Full +is caught). + +Fallback sync endpoint: POST /api/chat still exists and holds the connection open until +the agent finishes. The frontend never uses it but it can be useful for debugging. + +### 4.4 Agent Invocation (_run_agent_streaming) + + def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id): + +1. Fetches session from SESSIONS (not from disk -- session was just updated by /api/chat/start) +2. Sets TERMINAL_CWD, HERMES_EXEC_ASK, HERMES_SESSION_KEY env vars +3. Creates AIAgent with: + - model=model, platform='cli', quiet_mode=True + - enabled_toolsets=CLI_TOOLSETS (from config.yaml or hardcoded default) + - session_id=session_id + - stream_delta_callback=on_token (fires per token) + - tool_progress_callback=on_tool (fires per tool invocation) +4. Calls agent.run_conversation(user_message=msg_text, conversation_history=s.messages, + task_id=session_id) + NOTE: keyword is task_id NOT session_id (common mistake, documented in skill) +5. On return: updates s.messages, calls title_from(), saves session +6. Puts ('done', {session: ...}) into queue +7. Finally block: restores env vars, pops stream_id from STREAMS + +on_token callback: + if text is None: return # end-of-stream sentinel from AIAgent + put('token', {'text': text}) + +on_tool callback: + put('tool', {'name': name, 'preview': preview}) + # Also immediately surface any pending approval: + if has_pending(session_id): + with _lock: p = dict(_pending.get(session_id, {})) + if p: put('approval', p) + +The approval surface-on-tool logic means approvals appear immediately after the tool +fires (within the same SSE stream), without waiting for the next poll cycle. + +### 4.5 Approval System Integration + +The approval system uses the existing Hermes gateway module at tools/approval.py. +All state lives in module-level variables in that file: + + _pending = {} dict: session_key -> pending_entry_dict + _lock = Lock() protects _pending + _permanent_approved set of permanently approved pattern keys + +Because server.py imports tools.approval at module load time and everything runs in the +same process, this state IS shared between HTTP threads and agent daemon threads. + +Important: this only works because Python imports are cached (sys.modules). The same +module object is used everywhere. If the approval module were ever imported in a subprocess +or via importlib.reload(), this would break. + +GET /api/approval/pending: + - Peeks at _pending[sid] without removing it + - Returns {pending: entry} or {pending: null} + - Called by the browser every 1500ms while S.busy is true (polling fallback) + +POST /api/approval/respond: + - Pops _pending[sid] (removes it) + - For choice "once" or "session": calls approve_session(sid, pattern_key) for each key + - For choice "always": calls approve_session + approve_permanent + save_permanent_allowlist + - For choice "deny": just pops, does nothing (agent gets denied result) + - Returns {ok: true, choice: choice} + +### 4.6 File Upload Parser + +parse_multipart(rfile, content_type, content_length): + - Reads all content_length bytes from rfile into memory (up to MAX_UPLOAD_BYTES = 20MB) + - Extracts boundary from Content-Type header + - Splits raw bytes on b'--' + boundary + - For each part: parses MIME headers via email.parser.HeaderParser + - Returns (fields, files) where fields is {name: value} and files is {name: (filename, bytes)} + +handle_upload(handler): + - Calls parse_multipart() + - Validates: file field present, filename present, session exists + - Sanitizes filename: replaces non-word chars with _, truncates to 200 chars + - Writes bytes to session.workspace / safe_name + - Returns {filename, path, size} + +Why not cgi.FieldStorage: + - Deprecated in Python 3.11+ + - Broken for binary files (silently corrupts or throws) + - The manual parser handles all file types correctly + +### 4.7 File System Operations + +safe_resolve(root, requested): + - Resolves requested path relative to root + - Calls .relative_to(root) to assert the result is inside root + - Raises ValueError on path traversal (../../etc/passwd) + +list_dir(workspace, rel='.'): + - Calls safe_resolve, then iterdir() + - Sorts: directories first, then files, case-insensitive alpha within each group + - Returns up to 200 entries with {name, path, type, size} + +read_file_content(workspace, rel): + - Calls safe_resolve + - Enforces MAX_FILE_BYTES = 200KB size limit + - Reads as UTF-8 with errors='replace' (binary files show replacement chars) + - Returns {path, content, size, lines} + +--- + +## 5. Frontend Architecture: Current State + +### 5.1 Structure + +The entire frontend is ~750 lines inside the HTML Python raw string. +Structure: with CSS only (no external stylesheets), with three-panel layout, + + + + +
+ +
+
+
Hermes
Start a new conversation
+
+
GPT-5.4 Mini
+
+
📁 test-workspace ▾
+
+
+ +
+
+
+
+ +

What can I help with?

+

Ask anything, run commands, explore files, or manage your scheduled tasks.

+
+ + + +
+
+
+ +
+
+ ⚠ A response may have been in progress when you last left. Reload messages? +
+ + +
+
+
+
+
+ + Dangerous command — approval required +
+
+
+
+ + + + +
+
+
+ + +
+
+
+ + Drop files to upload to workspace +
+
+ + +
+
+
+
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/static/messages.js b/static/messages.js new file mode 100644 index 0000000..31a619a --- /dev/null +++ b/static/messages.js @@ -0,0 +1,310 @@ +async function send(){ + const text=$('msg').value.trim(); + if(!text&&!S.pendingFiles.length)return; + // Don't send while an inline message edit is active + if(document.querySelector('.msg-edit-area'))return; + // If busy, queue the message instead of dropping it + if(S.busy){ + if(text){ + MSG_QUEUE.push(text); + $('msg').value='';autoResize(); + updateQueueBadge(); + showToast(`Queued: "${text.slice(0,40)}${text.length>40?'\u2026':''}"`,2000); + } + return; + } + if(!S.session){await newSession();await renderSessionList();} + + const activeSid=S.session.session_id; + + setStatus(S.pendingFiles&&S.pendingFiles.length?'Uploading…':'Sending…'); + let uploaded=[]; + try{uploaded=await uploadPendingFiles();} + catch(e){if(!text){setStatus(`❌ ${e.message}`);return;}} + + let msgText=text; + 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(', ')}]`; + if(!msgText){setStatus('Nothing to send');return;} + + $('msg').value='';autoResize(); + const displayText=text||(uploaded.length?`Uploaded: ${uploaded.join(', ')}`:'(file upload)'); + const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploaded:undefined}; + S.toolCalls=[]; // clear tool calls from previous turn + clearLiveToolCards(); // clear any leftover live cards from last turn + S.messages.push(userMsg);renderMessages();appendThinking();setBusy(true); // activity bar shown via setBusy + INFLIGHT[activeSid]={messages:[...S.messages],uploaded}; + startApprovalPolling(activeSid); + S.activeStreamId = null; // will be set after stream starts + + // Set provisional title from user message immediately so session appears + // in the sidebar right away with a meaningful name (server may refine later) + if(S.session&&(S.session.title==='Untitled'||!S.session.title)){ + const provisionalTitle=displayText.slice(0,64); + S.session.title=provisionalTitle; + syncTopbar(); + // Persist it and refresh the sidebar now -- don't wait for done + api('/api/session/rename',{method:'POST',body:JSON.stringify({ + session_id:activeSid, title:provisionalTitle + })}).catch(()=>{}); // fire-and-forget, server refines on done + renderSessionList(); // session appears in sidebar immediately + } else { + renderSessionList(); // ensure it's visible even if already titled + } + + // Start the agent via POST, get a stream_id back + let streamId; + try{ + const startData=await api('/api/chat/start',{method:'POST',body:JSON.stringify({ + session_id:activeSid,message:msgText, + model:S.session.model||$('modelSelect').value,workspace:S.session.workspace, + attachments:uploaded.length?uploaded:undefined + })}); + streamId=startData.stream_id; + S.activeStreamId = streamId; + markInflight(activeSid, streamId); + // Show Cancel button + const cancelBtn=$('btnCancel'); + if(cancelBtn) cancelBtn.style.display=''; + }catch(e){ + delete INFLIGHT[activeSid]; + stopApprovalPolling(); + // Only hide approval card if it belongs to the session that just finished + if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();removeThinking(); + S.messages.push({role:'assistant',content:`**Error:** ${e.message}`}); + renderMessages();setBusy(false);setStatus('Error: '+e.message); + return; + } + + // Open SSE stream and render tokens live + let assistantText=''; + let assistantRow=null; + let assistantBody=null; + + function ensureAssistantRow(){ + if(assistantRow)return; + removeThinking(); + const tr=$('toolRunningRow');if(tr)tr.remove(); + $('emptyState').style.display='none'; + assistantRow=document.createElement('div');assistantRow.className='msg-row'; + assistantBody=document.createElement('div');assistantBody.className='msg-body'; + const role=document.createElement('div');role.className='msg-role assistant'; + const icon=document.createElement('div');icon.className='role-icon assistant';icon.textContent='H'; + const lbl=document.createElement('span');lbl.style.fontSize='12px';lbl.textContent='Hermes'; + role.appendChild(icon);role.appendChild(lbl); + assistantRow.appendChild(role);assistantRow.appendChild(assistantBody); + $('msgInner').appendChild(assistantRow); + } + + const es=new EventSource(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`); + + es.addEventListener('token',e=>{ + // Guard: if the user switched sessions, don't write tokens to the wrong DOM + if(!S.session||S.session.session_id!==activeSid) return; + const d=JSON.parse(e.data); + assistantText+=d.text; + ensureAssistantRow(); + assistantBody.innerHTML=renderMd(assistantText); + $('messages').scrollTop=$('messages').scrollHeight; + }); + + es.addEventListener('tool',e=>{ + const d=JSON.parse(e.data); + // Only update activity bar if viewing this session + 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; + removeThinking(); + const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove(); + // Append card to the stable live container -- no renderMessages() call + const tc={name:d.name, preview:d.preview||'', args:d.args||{}, snippet:'', done:false}; + S.toolCalls.push(tc); + appendLiveToolCard(tc); + $('messages').scrollTop=$('messages').scrollHeight; + }); + + es.addEventListener('approval',e=>{ + const d=JSON.parse(e.data); + // Tag the approval with the session that owns it so respondApproval uses correct sid + d._session_id=activeSid; + showApprovalCard(d); + }); + + es.addEventListener('done',e=>{ + es.close(); + const d=JSON.parse(e.data); + delete INFLIGHT[activeSid]; + clearInflight(); + stopApprovalPolling(); + // Only hide approval card if it belongs to the session that just finished + if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(); + // Only clear active stream state if this is the currently viewed session + if(S.session&&S.session.session_id===activeSid){ + S.activeStreamId=null; + const _cb=$('btnCancel');if(_cb)_cb.style.display='none'; + } + if(S.session&&S.session.session_id===activeSid){ + S.session=d.session;S.messages=d.session.messages||[]; + // Populate tool calls from server-extracted metadata (has snippets) + if(d.session.tool_calls&&d.session.tool_calls.length){ + S.toolCalls=d.session.tool_calls.map(tc=>({...tc,done:true})); + } else { + S.toolCalls=S.toolCalls.map(tc=>({...tc,done:true})); + } + if(uploaded.length){ + const lastUser=[...S.messages].reverse().find(m=>m.role==='user'); + if(lastUser)lastUser.attachments=uploaded; + } + clearLiveToolCards(); + // Set S.busy=false BEFORE renderMessages so the settled tool card + // block (!S.busy guard) can render the final grouped cards. + S.busy=false; + syncTopbar();renderMessages();loadDir('.'); + } + renderSessionList();setBusy(false);setStatus(''); + }); + + es.addEventListener('error',e=>{ + es.close(); + delete INFLIGHT[activeSid]; + clearInflight(); + stopApprovalPolling(); + // Only hide approval card if it belongs to the session that just finished + if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(); + if(S.session&&S.session.session_id===activeSid){ + S.activeStreamId=null; + const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none'; + } + let msg='Connection error'; + try{const d=JSON.parse(e.data);msg=d.message||msg;}catch(_){} + if(S.session&&S.session.session_id===activeSid){ + clearLiveToolCards(); + if(!assistantText){removeThinking();} + S.messages.push({role:'assistant',content:`**Error:** ${msg}`}); + renderMessages(); + } + if(!S.session || !INFLIGHT[S.session.session_id]){ + setBusy(false);setStatus('Error: '+msg); + } + }); + + es.addEventListener('cancel',e=>{ + es.close(); + delete INFLIGHT[activeSid]; + clearInflight(); + stopApprovalPolling(); + // Only hide approval card if it belongs to the session that just finished + if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(); + if(S.session&&S.session.session_id===activeSid){ + S.activeStreamId=null; + const _cbc=$('btnCancel');if(_cbc)_cbc.style.display='none'; + } + if(S.session&&S.session.session_id===activeSid){ + clearLiveToolCards(); + if(!assistantText){removeThinking();} + S.messages.push({role:'assistant',content:'*Task cancelled.*'}); + renderMessages(); + } + renderSessionList(); + if(!S.session || !INFLIGHT[S.session.session_id]){ + setBusy(false);setStatus(''); + } + }); + + // Handle SSE connection errors (network drop etc) + es.onerror=()=>{ + if(es.readyState===EventSource.CLOSED){ + delete INFLIGHT[activeSid]; + stopApprovalPolling(); + // Only hide approval card if it belongs to the session that just finished + if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(); + if(S.session&&S.session.session_id===activeSid){ + S.activeStreamId=null; + const _cbo=$('btnCancel');if(_cbo)_cbo.style.display='none'; + } + if(!assistantText&&S.session&&S.session.session_id===activeSid){ + removeThinking(); + S.messages.push({role:'assistant',content:'**Error:** Connection lost'}); + renderMessages(); + } + if(!S.session || !INFLIGHT[S.session.session_id]){ + setBusy(false);setStatus(''); + } + } + }; +} + +function transcript(){ + const lines=[`# Hermes session ${S.session?.session_id||''}`,``, + `Workspace: ${S.session?.workspace||''}`,`Model: ${S.session?.model||''}`,``]; + for(const m of S.messages){ + if(!m||m.role==='tool')continue; + let c=m.content||''; + if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('\n'); + const ct=String(c).trim(); + if(!ct&&!m.attachments?.length)continue; + const attach=m.attachments?.length?`\n\n_Files: ${m.attachments.join(', ')}_`:''; + lines.push(`## ${m.role}`,'',ct+attach,''); + } + return lines.join('\n'); +} + +function autoResize(){const el=$('msg');el.style.height='auto';el.style.height=Math.min(el.scrollHeight,200)+'px';} + + +// ── Approval polling ── +let _approvalPollTimer = null; + +// showApprovalCard moved above respondApproval + +function hideApprovalCard() { + $("approvalCard").classList.remove("visible"); + $("approvalCmd").textContent = ""; + $("approvalDesc").textContent = ""; +} + +// Track session_id of the active approval so respond goes to the right session +let _approvalSessionId = null; + +function showApprovalCard(pending) { + $("approvalDesc").textContent = pending.description || ""; + $("approvalCmd").textContent = pending.command || ""; + const keys = pending.pattern_keys || (pending.pattern_key ? [pending.pattern_key] : []); + $("approvalDesc").textContent = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : ""); + _approvalSessionId = pending._session_id || (S.session && S.session.session_id) || null; + $("approvalCard").classList.add("visible"); +} + +async function respondApproval(choice) { + const sid = _approvalSessionId || (S.session && S.session.session_id); + if (!sid) return; + hideApprovalCard(); + _approvalSessionId = null; + try { + await api("/api/approval/respond", { + method: "POST", + body: JSON.stringify({ session_id: sid, choice }) + }); + } catch(e) { setStatus("Approval error: " + e.message); } +} + +function startApprovalPolling(sid) { + stopApprovalPolling(); + _approvalPollTimer = setInterval(async () => { + if (!S.busy || !S.session || S.session.session_id !== sid) { + stopApprovalPolling(); hideApprovalCard(); return; + } + try { + const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid)); + if (data.pending) { data.pending._session_id=sid; showApprovalCard(data.pending); } + else { hideApprovalCard(); } + } catch(e) { /* ignore poll errors */ } + }, 1500); +} + +function stopApprovalPolling() { + if (_approvalPollTimer) { clearInterval(_approvalPollTimer); _approvalPollTimer = null; } +} +// ── Panel navigation (Chat / Tasks / Skills / Memory) ── + diff --git a/static/panels.js b/static/panels.js new file mode 100644 index 0000000..cf36959 --- /dev/null +++ b/static/panels.js @@ -0,0 +1,600 @@ +let _currentPanel = 'chat'; +let _skillsData = null; // cached skills list + +async function switchPanel(name) { + _currentPanel = name; + // Update nav tabs + document.querySelectorAll('.nav-tab').forEach(t => t.classList.toggle('active', t.dataset.panel === name)); + // Update panel views + document.querySelectorAll('.panel-view').forEach(p => p.classList.remove('active')); + const panelEl = $('panel' + name.charAt(0).toUpperCase() + name.slice(1)); + if (panelEl) panelEl.classList.add('active'); + // Lazy-load panel data + if (name === 'tasks') await loadCrons(); + if (name === 'skills') await loadSkills(); + if (name === 'memory') await loadMemory(); + if (name === 'workspaces') await loadWorkspacesPanel(); + if (name === 'todos') loadTodos(); +} + +// ── Cron panel ── +async function loadCrons() { + const box = $('cronList'); + try { + const data = await api('/api/crons'); + if (!data.jobs || !data.jobs.length) { + box.innerHTML = '
No scheduled jobs found.
'; + return; + } + box.innerHTML = ''; + for (const job of data.jobs) { + const item = document.createElement('div'); + item.className = 'cron-item'; + item.id = 'cron-' + job.id; + const statusClass = job.enabled === false ? 'disabled' : job.state === 'paused' ? 'paused' : job.last_status === 'error' ? 'error' : 'active'; + const statusLabel = job.enabled === false ? 'off' : job.state === 'paused' ? 'paused' : job.last_status === 'error' ? 'error' : 'active'; + const nextRun = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : 'N/A'; + const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : 'never'; + item.innerHTML = ` +
+ ${esc(job.name)} + ${statusLabel} +
+
+
🕑 ${esc(job.schedule_display || job.schedule?.expression || '')}  |  Next: ${esc(nextRun)}  |  Last: ${esc(lastRun)}
+
${esc((job.prompt||'').slice(0,300))}${(job.prompt||'').length>300?'…':''}
+
+ + ${statusLabel==='paused' + ? `` + : ``} + + +
+ + +
+
+ Last output + +
+
Loading…
+ +
+
`; + box.appendChild(item); + // Eagerly load last output for visible items + loadCronOutput(job.id); + } + } catch(e) { box.innerHTML = `
Error: ${esc(e.message)}
`; } +} + +function toggleCronForm(){ + const form=$('cronCreateForm'); + if(!form)return; + const open=form.style.display!=='none'; + form.style.display=open?'none':''; + if(!open){ + $('cronFormName').value=''; + $('cronFormSchedule').value=''; + $('cronFormPrompt').value=''; + $('cronFormDeliver').value='local'; + $('cronFormError').style.display='none'; + $('cronFormName').focus(); + } +} + +async function submitCronCreate(){ + const name=$('cronFormName').value.trim(); + const schedule=$('cronFormSchedule').value.trim(); + const prompt=$('cronFormPrompt').value.trim(); + const deliver=$('cronFormDeliver').value; + const errEl=$('cronFormError'); + errEl.style.display='none'; + if(!schedule){errEl.textContent='Schedule is required (e.g. "0 9 * * *" or "every 1h")';errEl.style.display='';return;} + if(!prompt){errEl.textContent='Prompt is required';errEl.style.display='';return;} + try{ + await api('/api/crons/create',{method:'POST',body:JSON.stringify({name:name||undefined,schedule,prompt,deliver})}); + toggleCronForm(); + showToast('Job created ✓'); + await loadCrons(); + }catch(e){ + errEl.textContent='Error: '+e.message;errEl.style.display=''; + } +} + +function _cronOutputSnippet(content) { + // Extract the response body from a cron output .md file + const lines = content.split('\n'); + const responseIdx = lines.findIndex(l => l.startsWith('## Response') || l.startsWith('# Response')); + const body = (responseIdx >= 0 ? lines.slice(responseIdx + 1) : lines).join('\n').trim(); + return body.slice(0, 600) || '(empty)'; +} + +async function loadCronOutput(jobId) { + try { + const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=1`); + const el = $('cron-out-text-' + jobId); + if (!el) return; + if (!data.outputs || !data.outputs.length) { el.textContent = '(no runs yet)'; return; } + const out = data.outputs[0]; + const ts = out.filename.replace('.md','').replace(/_/g,' '); + el.textContent = ts + '\n\n' + _cronOutputSnippet(out.content); + } catch(e) { /* ignore */ } +} + +async function loadCronHistory(jobId, btn) { + const histEl = $('cron-history-' + jobId); + if (!histEl) return; + // Toggle: if already open, close it + if (histEl.style.display !== 'none') { + histEl.style.display = 'none'; + if (btn) btn.textContent = 'All runs'; + return; + } + if (btn) btn.textContent = 'Loading…'; + try { + const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=20`); + if (!data.outputs || !data.outputs.length) { + histEl.innerHTML = '
(no runs yet)
'; + } else { + histEl.innerHTML = data.outputs.map((out, i) => { + const ts = out.filename.replace('.md','').replace(/_/g,' '); + const snippet = _cronOutputSnippet(out.content); + const id = `cron-hist-run-${jobId}-${i}`; + return `
+
+ ${esc(ts)} + +
+ +
`; + }).join(''); + } + histEl.style.display = ''; + if (btn) btn.textContent = 'Hide runs'; + } catch(e) { + if (btn) btn.textContent = 'All runs'; + } +} + +function toggleCron(id) { + const body = $('cron-body-' + id); + if (body) body.classList.toggle('open'); +} + +async function cronRun(id) { + try { + await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id})}); + showToast('Job triggered ✓'); + setTimeout(() => loadCronOutput(id), 5000); + } catch(e) { showToast('Run failed: ' + e.message, 4000); } +} + +async function cronPause(id) { + try { + await api('/api/crons/pause', {method:'POST', body: JSON.stringify({job_id: id})}); + showToast('Job paused'); + await loadCrons(); + } catch(e) { showToast('Pause failed: ' + e.message, 4000); } +} + +async function cronResume(id) { + try { + await api('/api/crons/resume', {method:'POST', body: JSON.stringify({job_id: id})}); + showToast('Job resumed ✓'); + await loadCrons(); + } catch(e) { showToast('Resume failed: ' + e.message, 4000); } +} + +function cronEditOpen(id, job) { + const form = $('cron-edit-' + id); + if (!form) return; + $('cron-edit-name-' + id).value = job.name || ''; + $('cron-edit-schedule-' + id).value = job.schedule_display || (job.schedule && job.schedule.expression) || job.schedule || ''; + $('cron-edit-prompt-' + id).value = job.prompt || ''; + const errEl = $('cron-edit-err-' + id); + if (errEl) errEl.style.display = 'none'; + form.style.display = ''; +} + +function cronEditClose(id) { + const form = $('cron-edit-' + id); + if (form) form.style.display = 'none'; +} + +async function cronEditSave(id) { + const name = $('cron-edit-name-' + id).value.trim(); + const schedule = $('cron-edit-schedule-' + id).value.trim(); + const prompt = $('cron-edit-prompt-' + id).value.trim(); + const errEl = $('cron-edit-err-' + id); + if (!schedule) { errEl.textContent = 'Schedule is required'; errEl.style.display = ''; return; } + if (!prompt) { errEl.textContent = 'Prompt is required'; errEl.style.display = ''; return; } + try { + const updates = {job_id: id, schedule, prompt}; + if (name) updates.name = name; + await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)}); + showToast('Job updated ✓'); + await loadCrons(); + } catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; } +} + +async function cronDelete(id) { + if (!confirm('Delete this cron job? This cannot be undone.')) return; + try { + await api('/api/crons/delete', {method:'POST', body: JSON.stringify({job_id: id})}); + showToast('Job deleted'); + await loadCrons(); + } catch(e) { showToast('Delete failed: ' + e.message, 4000); } +} + +function loadTodos() { + const panel = $('todoPanel'); + if (!panel) return; + // Parse the most recent todo state from message history + let todos = []; + for (let i = S.messages.length - 1; i >= 0; i--) { + const m = S.messages[i]; + if (m && m.role === 'tool') { + try { + const d = JSON.parse(typeof m.content === 'string' ? m.content : JSON.stringify(m.content)); + if (d && Array.isArray(d.todos) && d.todos.length) { + todos = d.todos; + break; + } + } catch(e) {} + } + } + if (!todos.length) { + panel.innerHTML = '
No active task list in this session.
'; + return; + } + const statusIcon = {pending:'○', in_progress:'◉', completed:'✓', cancelled:'✗'}; + 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 => ` +
+ ${statusIcon[t.status]||'○'} +
+
${esc(t.content)}
+
${esc(t.id)} · ${esc(t.status)}
+
+
`).join(''); +} + +async function clearConversation() { + if(!S.session) return; + if(!confirm('Clear all messages in this conversation? This cannot be undone.')) return; + try { + const data = await api('/api/session/clear', {method:'POST', + body: JSON.stringify({session_id: S.session.session_id})}); + S.session = data.session; + S.messages = []; + S.toolCalls = []; + syncTopbar(); + renderMessages(); + showToast('Conversation cleared'); + } catch(e) { setStatus('Clear failed: ' + e.message); } +} + +// ── Skills panel ── +async function loadSkills() { + if (_skillsData) { renderSkills(_skillsData); return; } + const box = $('skillsList'); + try { + const data = await api('/api/skills'); + _skillsData = data.skills || []; + renderSkills(_skillsData); + } catch(e) { box.innerHTML = `
Error: ${esc(e.message)}
`; } +} + +function renderSkills(skills) { + const query = ($('skillsSearch').value || '').toLowerCase(); + const filtered = query ? skills.filter(s => + (s.name||'').toLowerCase().includes(query) || + (s.description||'').toLowerCase().includes(query) || + (s.category||'').toLowerCase().includes(query) + ) : skills; + // Group by category + const cats = {}; + for (const s of filtered) { + const cat = s.category || '(general)'; + if (!cats[cat]) cats[cat] = []; + cats[cat].push(s); + } + const box = $('skillsList'); + box.innerHTML = ''; + if (!filtered.length) { box.innerHTML = '
No skills match.
'; return; } + for (const [cat, items] of Object.entries(cats).sort()) { + const sec = document.createElement('div'); + sec.className = 'skills-category'; + sec.innerHTML = `
📁 ${esc(cat)} (${items.length})
`; + for (const skill of items.sort((a,b) => a.name.localeCompare(b.name))) { + const el = document.createElement('div'); + el.className = 'skill-item'; + el.innerHTML = `${esc(skill.name)}${esc(skill.description||'')}`; + el.onclick = () => openSkill(skill.name, el); + sec.appendChild(el); + } + box.appendChild(sec); + } +} + +function filterSkills() { + if (_skillsData) renderSkills(_skillsData); +} + +async function openSkill(name, el) { + // Highlight active skill + document.querySelectorAll('.skill-item').forEach(e => e.classList.remove('active')); + if (el) el.classList.add('active'); + try { + const data = await api(`/api/skills/content?name=${encodeURIComponent(name)}`); + // Show skill content in right panel preview + $('previewPathText').textContent = name + '.md'; + $('previewBadge').textContent = 'skill'; + $('previewBadge').className = 'preview-badge md'; + showPreview('md'); + $('previewMd').innerHTML = renderMd(data.content || '(no content)'); + $('previewArea').classList.add('visible'); + $('fileTree').style.display = 'none'; + } catch(e) { setStatus('Could not load skill: ' + e.message); } +} + +// ── Skill create/edit form ── +let _editingSkillName = null; + +function toggleSkillForm(prefillName, prefillCategory, prefillContent) { + const form = $('skillCreateForm'); + if (!form) return; + const open = form.style.display !== 'none'; + if (open) { form.style.display = 'none'; _editingSkillName = null; return; } + $('skillFormName').value = prefillName || ''; + $('skillFormCategory').value = prefillCategory || ''; + $('skillFormContent').value = prefillContent || ''; + $('skillFormError').style.display = 'none'; + _editingSkillName = prefillName || null; + form.style.display = ''; + $('skillFormName').focus(); +} + +async function submitSkillSave() { + const name = ($('skillFormName').value||'').trim().toLowerCase().replace(/\s+/g, '-'); + const category = ($('skillFormCategory').value||'').trim(); + const content = $('skillFormContent').value; + const errEl = $('skillFormError'); + errEl.style.display = 'none'; + if (!name) { errEl.textContent = 'Skill name is required'; errEl.style.display = ''; return; } + if (!content.trim()) { errEl.textContent = 'Content is required'; errEl.style.display = ''; return; } + try { + await api('/api/skills/save', {method:'POST', body: JSON.stringify({name, category: category||undefined, content})}); + showToast(_editingSkillName ? 'Skill updated ✓' : 'Skill created ✓'); + _skillsData = null; + toggleSkillForm(); + await loadSkills(); + } catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; } +} + +// ── Memory inline edit ── +let _memoryData = null; + +function toggleMemoryEdit() { + const form = $('memoryEditForm'); + if (!form) return; + const open = form.style.display !== 'none'; + if (open) { form.style.display = 'none'; return; } + $('memEditSection').textContent = 'memory (notes)'; + $('memEditContent').value = _memoryData ? (_memoryData.memory || '') : ''; + $('memEditError').style.display = 'none'; + form.style.display = ''; +} + +function closeMemoryEdit() { + const form = $('memoryEditForm'); + if (form) form.style.display = 'none'; +} + +async function submitMemorySave() { + const content = $('memEditContent').value; + const errEl = $('memEditError'); + errEl.style.display = 'none'; + try { + await api('/api/memory/write', {method:'POST', body: JSON.stringify({section: 'memory', content})}); + showToast('Memory saved ✓'); + closeMemoryEdit(); + await loadMemory(true); + } catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; } +} + +// ── Workspace management ── +let _workspaceList = []; // cached from /api/workspaces + +function getWorkspaceFriendlyName(path){ + // Look up the friendly name from the workspace list cache, fallback to last path segment + if(_workspaceList && _workspaceList.length){ + const match=_workspaceList.find(w=>w.path===path); + if(match && match.name) return match.name; + } + return path.split('/').filter(Boolean).pop()||path; +} + +async function loadWorkspaceList(){ + try{ + const data = await api('/api/workspaces'); + _workspaceList = data.workspaces || []; + // Refresh sidebar display if we have a current session + 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; + }catch(e){ return {workspaces:[], last:''}; } +} + +function renderWorkspaceDropdown(workspaces, currentWs){ + const dd = $('wsDropdown'); + if(!dd)return; + dd.innerHTML=''; + for(const w of workspaces){ + const opt=document.createElement('div'); + opt.className='ws-opt'+(w.path===currentWs?' active':''); + opt.innerHTML=`${esc(w.name)}${esc(w.path)}`; + opt.onclick=async()=>{ + 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); + } + // Divider + Manage link + const div=document.createElement('div');div.className='ws-divider';dd.appendChild(div); + const mgmt=document.createElement('div');mgmt.className='ws-opt ws-manage'; + mgmt.innerHTML='⚙ Manage workspaces'; + mgmt.onclick=()=>{closeWsDropdown();switchPanel('workspaces');}; + dd.appendChild(mgmt); +} + +function toggleWsDropdown(){ + const dd=$('wsDropdown'); + if(!dd)return; + const open=dd.classList.contains('open'); + if(open){closeWsDropdown();} + else{ + loadWorkspaceList().then(data=>{ + renderWorkspaceDropdown(data.workspaces, S.session?S.session.workspace:''); + dd.classList.add('open'); + }); + } +} + +function closeWsDropdown(){ + const dd=$('wsDropdown'); + if(dd)dd.classList.remove('open'); +} +document.addEventListener('click',e=>{ + if(!e.target.closest('#wsChipWrap'))closeWsDropdown(); +}); + +async function loadWorkspacesPanel(){ + const panel=$('workspacesPanel'); + if(!panel)return; + const data=await loadWorkspaceList(); + renderWorkspacesPanel(data.workspaces); +} + +function renderWorkspacesPanel(workspaces){ + const panel=$('workspacesPanel'); + panel.innerHTML=''; + for(const w of workspaces){ + const row=document.createElement('div');row.className='ws-row'; + row.innerHTML=` +
+
${esc(w.name)}
+
${esc(w.path)}
+
+
+ + +
`; + panel.appendChild(row); + } + const addRow=document.createElement('div');addRow.className='ws-add-row'; + addRow.innerHTML=` + + `; + panel.appendChild(addRow); + const hint=document.createElement('div'); + hint.style.cssText='font-size:11px;color:var(--muted);padding:4px 0 8px'; + hint.textContent='Paths are validated as existing directories before saving.'; + panel.appendChild(hint); +} + +async function addWorkspace(){ + const input=$('wsAddInput'); + const path=(input?input.value:'').trim(); + if(!path)return; + try{ + const data=await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path})}); + _workspaceList=data.workspaces; + renderWorkspacesPanel(data.workspaces); + if(input)input.value=''; + showToast('Workspace added'); + }catch(e){setStatus('Add failed: '+e.message);} +} + +async function removeWorkspace(path){ + if(!confirm(`Remove workspace "${path}"?`))return; + try{ + const data=await api('/api/workspaces/remove',{method:'POST',body:JSON.stringify({path})}); + _workspaceList=data.workspaces; + renderWorkspacesPanel(data.workspaces); + showToast('Workspace removed'); + }catch(e){setStatus('Remove failed: '+e.message);} +} + +async function switchToWorkspace(path,name){ + if(!S.session)return; + try{ + await api('/api/session/update',{method:'POST',body:JSON.stringify({ + session_id:S.session.session_id, workspace:path, model:S.session.model + })}); + S.session.workspace=path; + syncTopbar(); + await loadDir('.'); + showToast(`Switched to ${name}`); + }catch(e){setStatus('Switch failed: '+e.message);} +} + +// ── Memory panel ── +async function loadMemory(force) { + const panel = $('memoryPanel'); + try { + const data = await api('/api/memory'); + _memoryData = data; // cache for edit form + const fmtTime = ts => ts ? new Date(ts*1000).toLocaleString() : ''; + panel.innerHTML = ` +
+
+ 🧠 My Notes + ${fmtTime(data.memory_mtime)} +
+ ${data.memory + ? `
${renderMd(data.memory)}
` + : '
No notes yet.
'} +
+
+
+ 👤 User Profile + ${fmtTime(data.user_mtime)} +
+ ${data.user + ? `
${renderMd(data.user)}
` + : '
No profile yet.
'} +
`; + } catch(e) { panel.innerHTML = `
Error: ${esc(e.message)}
`; } +} + +// Drag and drop +const wrap=$('composerWrap');let dragCounter=0; +document.addEventListener('dragover',e=>e.preventDefault()); +document.addEventListener('dragenter',e=>{e.preventDefault();if(e.dataTransfer.types.includes('Files')){dragCounter++;wrap.classList.add('drag-over');}}); +document.addEventListener('dragleave',e=>{dragCounter--;if(dragCounter<=0){dragCounter=0;wrap.classList.remove('drag-over');}}); +document.addEventListener('drop',e=>{e.preventDefault();dragCounter=0;wrap.classList.remove('drag-over');const files=Array.from(e.dataTransfer.files);if(files.length){addFiles(files);$('msg').focus();}}); + +// Event wiring diff --git a/static/sessions.js b/static/sessions.js new file mode 100644 index 0000000..172a105 --- /dev/null +++ b/static/sessions.js @@ -0,0 +1,206 @@ +async function newSession(flash){ + MSG_QUEUE.length=0;updateQueueBadge(); + S.toolCalls=[]; + clearLiveToolCards(); + const inheritWs=S.session?S.session.workspace:null; + 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('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) + +async function renderSessionList(){ + try{ + if(!($('sessionSearch').value||'').trim()) _contentSearchResults = []; + const data=await api('/api/sessions'); + _allSessions = data.sessions||[]; + renderSessionListFromCache(); // no-ops if rename is in progress + }catch(e){console.warn('renderSessionList',e);} +} + +let _searchDebounceTimer = null; +let _contentSearchResults = []; // results from /api/sessions/search content scan + +function filterSessions(){ + // Immediate client-side title filter (no flicker) + renderSessionListFromCache(); + // Debounced content search via API for message text + const q = ($('sessionSearch').value || '').trim(); + clearTimeout(_searchDebounceTimer); + if (!q) { _contentSearchResults = []; return; } + _searchDebounceTimer = setTimeout(async () => { + try { + const data = await api(`/api/sessions/search?q=${encodeURIComponent(q)}&content=1&depth=5`); + const titleIds = new Set(_allSessions.filter(s => (s.title||'Untitled').toLowerCase().includes(q.toLowerCase())).map(s=>s.session_id)); + _contentSearchResults = (data.sessions||[]).filter(s => s.match_type === 'content' && !titleIds.has(s.session_id)); + renderSessionListFromCache(); + } catch(e) { /* ignore */ } + }, 350); +} + +function renderSessionListFromCache(){ + // Don't re-render while user is actively renaming a session (would destroy the input) + if(_renamingSid) return; + const q=($('sessionSearch').value||'').toLowerCase(); + const titleMatches=q?_allSessions.filter(s=>(s.title||'Untitled').toLowerCase().includes(q)):_allSessions; + // Merge content matches (deduped): content matches appended after title matches + const titleIds=new Set(titleMatches.map(s=>s.session_id)); + const sessions=q?[...titleMatches,..._contentSearchResults.filter(s=>!titleIds.has(s.session_id))]:titleMatches; + const list=$('sessionList');list.innerHTML=''; + // Date grouping: Today / Yesterday / Earlier + const now=Date.now(); + const ONE_DAY=86400000; + let lastGroup=''; + for(const s of sessions.slice(0,50)){ + const ts=(s.updated_at||0)*1000; + const group=ts>now-ONE_DAY?'Today':ts>now-2*ONE_DAY?'Yesterday':'Earlier'; + if(group!==lastGroup){ + lastGroup=group; + const hdr=document.createElement('div'); + hdr.style.cssText='font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:10px 10px 4px;opacity:.8;'; + hdr.textContent=group; + list.appendChild(hdr); + } + const el=document.createElement('div'); + const isActive=S.session&&s.session_id===S.session.session_id; + el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':''); + if(isActive&&S.session&&S.session._flash)delete S.session._flash; + const title=document.createElement('span'); + title.className='session-title';title.textContent=s.title||'Untitled'; + title.title='Double-click to rename'; + + // Rename: called directly when we confirm it's a double-click + const startRename=()=>{ + _renamingSid = s.session_id; + const inp=document.createElement('input'); + inp.className='session-title-input'; + inp.value=s.title||'Untitled'; + ['click','mousedown','dblclick','pointerdown'].forEach(ev=> + inp.addEventListener(ev, e2=>e2.stopPropagation()) + ); + const finish=async(save)=>{ + _renamingSid = null; + if(save){ + const newTitle=inp.value.trim()||'Untitled'; + title.textContent=newTitle; + s.title=newTitle; + if(S.session&&S.session.session_id===s.session_id){S.session.title=newTitle;syncTopbar();} + try{await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:s.session_id,title:newTitle})});} + catch(err){setStatus('Rename failed: '+err.message);} + } + inp.replaceWith(title); + // Allow list re-renders again after a short delay + setTimeout(()=>{ if(_renamingSid===null) renderSessionListFromCache(); },50); + }; + inp.onkeydown=e2=>{ + if(e2.key==='Enter'){e2.preventDefault();e2.stopPropagation();finish(true);} + if(e2.key==='Escape'){e2.preventDefault();e2.stopPropagation();finish(false);} + }; + // onblur: cancel only -- no accidental saves + inp.onblur=()=>{ if(_renamingSid===s.session_id) finish(false); }; + title.replaceWith(inp); + setTimeout(()=>{inp.focus();inp.select();},10); + }; + + const trash=document.createElement('button'); + trash.className='session-trash';trash.innerHTML='🗑';trash.title='Delete'; + trash.onclick=async(e)=>{e.stopPropagation();e.preventDefault();await deleteSession(s.session_id);}; + el.appendChild(title);el.appendChild(trash); + + // Use a click timer to distinguish single-click (navigate) from double-click (rename). + // This prevents loadSession from firing on the first click of a double-click, + // which would re-render the list and destroy the dblclick target before it fires. + let _clickTimer=null; + el.onclick=async(e)=>{ + if(_renamingSid) return; // ignore while any rename is active + if(e.target===trash||trash.contains(e.target)) return; // trash handles itself + clearTimeout(_clickTimer); + _clickTimer=setTimeout(async()=>{ + _clickTimer=null; + if(_renamingSid) return; + await loadSession(s.session_id);renderSessionListFromCache(); + }, 220); + }; + el.ondblclick=async(e)=>{ + e.stopPropagation(); + e.preventDefault(); + clearTimeout(_clickTimer); // cancel the pending single-click navigation + _clickTimer=null; + startRename(); + }; + list.appendChild(el); + } +} + +async function deleteSession(sid){ + if(!confirm('Delete this conversation?'))return; + try{ + await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})}); + }catch(e){setStatus(`Delete failed: ${e.message}`);return;} + if(S.session&&S.session.session_id===sid){ + S.session=null;S.messages=[];S.entries=[]; + localStorage.removeItem('hermes-webui-session'); + // load the most recent remaining session, or show blank if none left + const remaining=await api('/api/sessions'); + if(remaining.sessions&&remaining.sessions.length){ + await loadSession(remaining.sessions[0].session_id); + }else{ + $('topbarTitle').textContent='Hermes'; + $('topbarMeta').textContent='Start a new conversation'; + $('msgInner').innerHTML=''; + $('emptyState').style.display=''; + $('fileTree').innerHTML=''; + } + } + showToast('Conversation deleted'); + await renderSessionList(); +} + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..3e3b218 --- /dev/null +++ b/static/style.css @@ -0,0 +1,450 @@ + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + :root { + --bg:#1a1a2e;--sidebar:#16213e;--border:rgba(255,255,255,0.08);--border2:rgba(255,255,255,0.14); + --text:#e8e8f0;--muted:#8888aa;--accent:#e94560;--blue:#7cb9ff;--gold:#c9a84c;--code-bg:#0d1117; + font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-size:14px;line-height:1.6; + } + body{background:var(--bg);color:var(--text);height:100vh;overflow:hidden;display:flex;} + .layout{display:flex;width:100%;height:100vh;} + .sidebar{width:300px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;} + .sidebar-header{padding:16px 18px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;} + .logo{width:32px;height:32px;border-radius:9px;background:linear-gradient(145deg,#e8a030,var(--accent));display:flex;align-items:center;justify-content:center;font-weight:800;font-size:14px;color:#fff;flex-shrink:0;box-shadow:0 2px 8px rgba(233,69,96,.3);} + .sidebar-header h1{font-size:15px;font-weight:600;} + .sidebar-section{padding:14px 14px 8px;} + .new-chat-btn{width:100%;padding:9px 12px;border-radius:9px;background:rgba(124,185,255,0.07);border:1px solid rgba(124,185,255,0.18);color:var(--blue);font-size:13px;cursor:pointer;display:flex;align-items:center;gap:8px;transition:all .15s;margin-bottom:8px;font-weight:500;} + .new-chat-btn:hover{background:rgba(124,185,255,0.13);border-color:rgba(124,185,255,.3);} + .session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;} + .session-search{padding:4px 10px 8px;flex-shrink:0;} + .session-search input{width:100%;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:8px;color:var(--text);padding:7px 12px;font-size:12px;outline:none;transition:all .15s;} + .session-search input:focus{border-color:rgba(124,185,255,.35);background:rgba(255,255,255,.06);box-shadow:0 0 0 2px rgba(124,185,255,.07);} + .session-search input::placeholder{color:var(--muted);opacity:.7;} + /* Inline session title edit */ + .session-title-input{flex:1;background:rgba(20,32,60,.9);border:1px solid rgba(124,185,255,.6);border-radius:6px;color:var(--text);padding:3px 8px;font-size:13px;outline:none;min-width:0;box-shadow:0 0 0 2px rgba(124,185,255,.15);font-family:inherit;} + .session-item{padding:8px 10px 8px 8px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:background .15s,color .15s,border-color .15s;display:flex;align-items:center;gap:6px;min-width:0;border-left:2px solid transparent;} + .session-item:hover{background:rgba(255,255,255,0.06);color:var(--text);} + .session-item.active{background:rgba(124,185,255,0.1);color:var(--blue);border-left:2px solid var(--blue);padding-left:8px;} + .session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} + .session-trash{flex-shrink:0;opacity:0;font-size:13px;color:var(--muted);background:none;border:none;cursor:pointer;padding:0 2px;line-height:1;transition:opacity .15s,color .15s;} + .session-item:hover .session-trash{opacity:1;} + .session-trash:hover{color:var(--accent)!important;} + @keyframes newflash{0%{background:rgba(124,185,255,0.22);color:var(--blue);}100%{background:transparent;color:var(--muted);}} + .session-item.new-flash{animation:newflash 1.4s ease-out forwards;} + .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(20,30,50,.95);backdrop-filter:blur(12px);border:1px solid rgba(124,185,255,0.25);color:var(--text);font-size:13px;padding:10px 20px;border-radius:12px;pointer-events:none;opacity:0;transition:opacity .2s,transform .2s;z-index:100;box-shadow:0 4px 20px rgba(0,0,0,.3);letter-spacing:.01em;} + .toast.show{opacity:1;transform:translateX(-50%) translateY(-2px);} + .reconnect-banner{display:none;background:#1a2535;border:1px solid rgba(201,168,76,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--gold);display:none;align-items:center;justify-content:space-between;gap:12px;} + .reconnect-banner.visible{display:flex;} + .reconnect-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(201,168,76,0.15);border:1px solid rgba(201,168,76,0.4);color:var(--gold);cursor:pointer;} + .reconnect-btn:hover{background:rgba(201,168,76,0.25);} + /* ── Approval card ── */ + .approval-card{display:none;max-width:780px;margin:0 auto 0;padding:0 20px 12px;} + .approval-card.visible{display:block;} + .approval-inner{background:rgba(20,30,50,.95);backdrop-filter:blur(8px);border:1px solid rgba(233,69,96,0.35);border-radius:14px;padding:14px 16px;} + .approval-header{display:flex;align-items:center;gap:8px;margin-bottom:10px;font-size:13px;font-weight:600;color:#e94560;} + .approval-desc{font-size:12px;color:var(--muted);margin-bottom:8px;} + .approval-cmd{background:var(--code-bg);border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:8px 12px;font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:#e2e8f0;white-space:pre-wrap;word-break:break-all;margin-bottom:12px;max-height:120px;overflow-y:auto;} + .approval-btns{display:flex;gap:8px;flex-wrap:wrap;} + .approval-btn{padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;border:1px solid var(--border2);background:rgba(255,255,255,0.06);color:var(--text);cursor:pointer;transition:all .15s;} + .approval-btn:hover{background:rgba(255,255,255,0.12);} + .approval-btn.once{border-color:rgba(124,185,255,0.5);color:var(--blue);} + .approval-btn.once:hover{background:rgba(124,185,255,0.15);} + .approval-btn.session{border-color:rgba(124,185,255,0.3);color:var(--blue);} + .approval-btn.always{border-color:rgba(201,168,76,0.5);color:var(--gold);} + .approval-btn.deny{border-color:rgba(233,69,96,0.5);color:var(--accent);} + .approval-btn.deny:hover{background:rgba(233,69,96,0.12);} + /* Sidebar navigation tabs */ + .sidebar-nav{display:flex;border-bottom:1px solid var(--border);flex-shrink:0;padding:6px 8px 0;gap:2px;} + .nav-tab{flex:1;padding:10px 4px 8px;font-size:20px;text-align:center;cursor:pointer;color:var(--muted);border:none;background:none;transition:color .15s;border-bottom:2px solid transparent;white-space:nowrap;overflow:hidden;position:relative;} + .nav-tab:hover{color:var(--text);} + .nav-tab:hover::after{content:attr(data-label);position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:rgba(15,22,40,.98);border:1px solid rgba(124,185,255,0.3);color:var(--blue);font-size:12px;font-weight:700;letter-spacing:.02em;padding:5px 11px;border-radius:7px;white-space:nowrap;pointer-events:none;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.3);} + .nav-tab.active{color:var(--blue);} + .nav-tab.active::before{content:'';position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:20px;height:2px;background:var(--blue);border-radius:2px 2px 0 0;} + /* Panel content areas (swapped by tab) */ + .panel-view{display:none;flex:1;overflow:hidden;flex-direction:column;} + .panel-view.active{display:flex;} + /* Cron panel */ + .cron-list{flex:1;overflow-y:auto;padding:8px;} + .cron-item{border-radius:10px;border:1px solid rgba(255,255,255,.08);margin-bottom:6px;overflow:hidden;transition:border-color .15s,background .15s;background:rgba(255,255,255,.02);} + .cron-item:hover{border-color:var(--border2);} + .cron-header{display:flex;align-items:center;gap:8px;padding:9px 11px;cursor:pointer;} + .cron-name{flex:1;font-size:13px;color:var(--text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} + .cron-status{font-size:10px;font-weight:700;padding:2px 7px;border-radius:99px;flex-shrink:0;} + .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.disabled{background:rgba(255,255,255,.07);color:var(--muted);} + .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.open{display:block;} + .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-actions{display:flex;gap:6px;margin-bottom:8px;} + .cron-btn{padding:4px 10px;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;} + .cron-btn:hover{background:rgba(255,255,255,.1);color:var(--text);} + .cron-btn.run{border-color:rgba(124,185,255,.4);color:var(--blue);} + .cron-btn.run:hover{background:rgba(124,185,255,.12);} + .cron-btn.pause{border-color:rgba(201,168,76,.4);color:var(--gold);} + .cron-last{font-size:11px;color:var(--muted);border-top:1px solid var(--border);padding-top:8px;max-height:220px;overflow-y:auto;white-space:pre-wrap;line-height:1.5;word-break:break-word;} + .cron-last-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:4px;} + /* Skills panel */ + .skills-search{padding:8px;flex-shrink:0;} + .skills-search input{width:100%;background:rgba(255,255,255,.06);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:6px 10px;font-size:12px;outline:none;} + .skills-search input::placeholder{color:var(--muted);} + .skills-list{flex:1;overflow-y:auto;padding:0 8px 8px;} + .skills-category{margin-bottom:4px;} + .skills-cat-header{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:8px 6px 4px;cursor:pointer;display:flex;align-items:center;gap:4px;} + .skills-cat-header:hover{color:var(--text);} + .skill-item{padding:7px 10px;border-radius:7px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:flex-start;gap:6px;transition:all .12s;line-height:1.4;} + .skill-item:hover{background:rgba(255,255,255,.06);color:var(--text);} + .skill-item.active{background:rgba(124,185,255,.1);color:var(--blue);} + .skill-name{font-weight:500;flex-shrink:0;} + .skill-desc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;font-size:11px;opacity:.7;} + /* Memory panel */ + .memory-panel{flex:1;overflow-y:auto;padding:12px;} + .memory-section{margin-bottom:16px;} + .memory-section-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:8px;display:flex;align-items:center;justify-content:space-between;} + .memory-mtime{font-size:10px;font-weight:400;text-transform:none;opacity:.6;} + .memory-content{font-size:12px;line-height:1.7;color:var(--text);} + .memory-content p{margin-bottom:6px;} + .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;} + .field-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;opacity:.8;} + select{width:100%;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.1);border-radius:8px;color:var(--text);padding:7px 28px 7px 10px;font-size:12px;outline:none;appearance:none;margin-bottom:6px;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238888aa' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;} + select:focus{border-color:rgba(124,185,255,.4);box-shadow:0 0 0 2px rgba(124,185,255,.08);} + optgroup{color:var(--muted);font-size:11px;font-weight:700;} + option{background:#1a1a2e;color:var(--text);padding:6px;} + .sidebar-actions{display:flex;gap:6px;} + .sm-btn{flex:1;padding:7px 0;border-radius:8px;font-size:11px;font-weight:500;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.08);color:var(--muted);cursor:pointer;transition:all .15s;text-align:center;letter-spacing:.02em;} + .sm-btn:hover{background:rgba(255,255,255,0.09);color:var(--text);border-color:rgba(255,255,255,.15);} + .main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;background:rgba(26,26,46,0.5);} + .topbar{padding:12px 20px;border-bottom:1px solid var(--border);background:rgba(22,33,62,.98);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;} + .topbar-title{font-size:15px;font-weight:600;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + .topbar-meta{font-size:11px;color:var(--muted);margin-top:3px;opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + .topbar-chips{display:flex;gap:6px;align-items:center;flex-shrink:0;} + .chip{font-size:11px;padding:4px 10px;border-radius:999px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,.1);color:var(--muted);font-weight:500;} + .chip.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;} + .messages-inner{max-width:800px;margin:0 auto;width:100%;padding:20px 24px 32px;display:flex;flex-direction:column;} + .msg-row{padding:10px 0;} + .msg-row+.msg-row{border-top:none;} + .msg-role{font-size:12px;font-weight:500;letter-spacing:.01em;margin-bottom:8px;display:flex;align-items:center;gap:8px;} + .msg-role.user{color:rgba(124,185,255,0.65);} + .msg-role.assistant{color:rgba(201,168,76,0.6);} + .role-icon{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0;} + .role-icon.user{background:rgba(124,185,255,0.15);color:var(--blue);border:1px solid rgba(124,185,255,0.2);} + .role-icon.assistant{background:rgba(201,168,76,0.15);color:var(--gold);border:1px solid rgba(201,168,76,0.2);} + .msg-body{font-size:14px;line-height:1.75;color:var(--text);padding-left:30px;max-width:680px;} + .msg-body p{margin-bottom:10px;}.msg-body p:last-child{margin-bottom:0;} + .msg-body ul,.msg-body ol{margin:6px 0 10px 20px;}.msg-body li{margin-bottom:3px;} + .msg-body h1,.msg-body h2,.msg-body h3{margin:16px 0 6px;font-weight:600;} + .msg-body h1{font-size:18px;}.msg-body h2{font-size:16px;}.msg-body h3{font-size:14px;} + .msg-body strong{color:#fff;font-weight:600;}.msg-body em{color:#c9c9e8;font-style:italic;} + .msg-body code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12.5px;background:rgba(0,0,0,.35);padding:1px 5px;border-radius:4px;color:#f0c27f;} + .msg-body pre{background:var(--code-bg);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:14px 16px;overflow-x:auto;margin:10px 0;} + .msg-body pre code{background:none;padding:0;border-radius:0;color:#e2e8f0;font-size:13px;line-height:1.6;} + .pre-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:8px 16px 8px;background:rgba(255,255,255,.04);border-radius:10px 10px 0 0;border:1px solid rgba(255,255,255,.08);border-bottom:1px solid rgba(255,255,255,.05);display:flex;align-items:center;gap:6px;} + .pre-header::before{content:'';width:8px;height:8px;border-radius:50%;background:var(--muted);opacity:.4;} + .pre-header+pre{border-radius:0 0 10px 10px;border-top:none;margin-top:0;} + .msg-body blockquote{border-left:3px solid var(--blue);padding-left:14px;color:var(--muted);font-style:italic;margin:10px 0;} + .msg-body a{color:var(--blue);text-decoration:underline;} + .msg-body hr{border:none;border-top:1px solid var(--border);margin:14px 0;} + .msg-files{display:flex;flex-wrap:wrap;gap:6px;padding-left:30px;margin-bottom:10px;} + .msg-file-badge{display:flex;align-items:center;gap:5px;background:rgba(124,185,255,0.1);border:1px solid rgba(124,185,255,0.25);border-radius:6px;padding:4px 9px;font-size:12px;color:var(--blue);} + .thinking{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:13px;padding-left:30px;} + .dot{width:6px;height:6px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out infinite;} + .dot:nth-child(2){animation-delay:.22s;}.dot:nth-child(3){animation-delay:.44s;} + @keyframes pulse{0%,80%,100%{opacity:.2;transform:scale(.8)}40%{opacity:.8;transform:scale(1)}} + .empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:40px;color:var(--muted);} + .empty-logo{width:64px;height:64px;border-radius:20px;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;font-size:28px;font-weight:700;color:var(--blue);margin-bottom:4px;box-shadow:0 4px 20px rgba(124,185,255,.1);} + .empty-state h2{font-size:20px;color:var(--text);font-weight:700;letter-spacing:-.02em;} + .empty-state p{font-size:14px;text-align:center;max-width:320px;} + .suggestion-grid{display:flex;flex-direction:column;gap:8px;margin-top:12px;width:100%;max-width:380px;} + .suggestion{padding:11px 14px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.08);border-radius:10px;font-size:13px;color:var(--muted);cursor:pointer;transition:all .15s;text-align:left;} + .suggestion:hover{background:rgba(124,185,255,0.07);color:var(--text);border-color:rgba(124,185,255,.3);transform:translateX(2px);} + /* ── Composer ── */ + .composer-wrap{border-top:1px solid var(--border);padding:12px 20px 16px;background:var(--bg);flex-shrink:0;} + .composer-box{max-width:780px;margin:0 auto;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.12);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;} + .composer-box:focus-within{border-color:rgba(124,185,255,0.5);box-shadow:0 0 0 3px rgba(124,185,255,0.08);} + .composer-wrap.drag-over .composer-box{border-color:var(--blue);background:rgba(124,185,255,0.06);} + .drop-hint{display:none;position:absolute;inset:0;align-items:center;justify-content:center;background:rgba(124,185,255,0.08);border:2px dashed var(--blue);border-radius:14px;font-size:14px;color:var(--blue);pointer-events:none;z-index:10;flex-direction:column;gap:8px;} + .composer-wrap.drag-over .drop-hint{display:flex;} + .attach-tray{display:none;flex-wrap:wrap;gap:6px;padding:10px 14px 0;} + .attach-tray.has-files{display:flex;} + .attach-chip{display:flex;align-items:center;gap:5px;background:rgba(124,185,255,0.08);border:1px solid rgba(124,185,255,0.22);border-radius:8px;padding:4px 10px;font-size:11px;font-weight:500;color:var(--blue);} + .attach-chip button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;line-height:1;padding:0 0 0 3px;} + .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::placeholder{color:var(--muted);} + .composer-footer{display:flex;align-items:center;justify-content:space-between;padding:6px 10px 10px;} + .composer-left{display:flex;gap:2px;align-items:center;} + .composer-right{display:flex;gap:6px;align-items:center;} + .icon-btn{width:34px;height:34px;border-radius:8px;background:none;border:none;color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;transition:all .15s;} + .icon-btn{opacity:.75;} + .icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);opacity:1;} + .status-text{font-size:11px;color:var(--muted);padding-left:4px;} + .send-btn{padding:7px 18px;border-radius:10px;font-size:13px;font-weight:600;background:linear-gradient(135deg,#5ba8f5,#7cb9ff);border:none;color:#0a1628;cursor:pointer;display:flex;align-items:center;gap:6px;transition:all .15s;flex-shrink:0;letter-spacing:.01em;} + .send-btn:hover{background:linear-gradient(135deg,#7cb9ff,#a0d0ff);transform:translateY(-1px);} + .send-btn:active{transform:translateY(0);} + .send-btn:disabled{opacity:.4;cursor:not-allowed;} + .upload-bar-wrap{display:none;height:3px;background:rgba(255,255,255,.06);border-radius:0 0 16px 16px;overflow:hidden;} + .upload-bar-wrap.active{display:block;} + .upload-bar{height:100%;background:linear-gradient(90deg,var(--blue),#a0d0ff);width:0%;transition:width .3s ease;} + .rightpanel{width:300px;background:var(--sidebar);border-left:1px solid rgba(255,255,255,.06);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;} + .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-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:hover{background:rgba(255,255,255,.08);color:var(--text);} + /* File row actions (shown on hover) */ + /* 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:hover{color:var(--accent);} + .close-preview{cursor:pointer;opacity:.6;}.close-preview:hover{opacity:1;} + .file-tree{flex:1;overflow-y:auto;padding:8px;} + .file-item{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:7px;cursor:pointer;font-size:12px;color:var(--muted);transition:all .12s;min-width:0;} + .file-item:hover{background:rgba(255,255,255,.07);color:var(--text);} + .file-item.active{background:rgba(124,185,255,.12);color:var(--blue);} + .preview-area{flex:1;overflow:auto;padding:14px;flex-direction:column;gap:8px;display:none;opacity:0;transition:opacity .15s;} + .preview-area.visible{display:flex;opacity:1;} + .preview-path{font-size:11px;color:var(--muted);padding-bottom:8px;border-bottom:1px solid var(--border);flex-shrink:0;} + .preview-code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-word;color:#cdd6e0;} + /* Image preview */ + .preview-img-wrap{display:flex;align-items:center;justify-content:center;flex:1;padding:8px 0;min-height:0;} + .preview-img{max-width:100%;max-height:100%;object-fit:contain;border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,.4);} + /* Markdown rendered preview */ + .preview-md{font-size:13px;line-height:1.7;color:var(--text);flex:1;overflow-y:auto;min-height:0;} + .preview-md p{margin-bottom:10px;}.preview-md p:last-child{margin-bottom:0;} + .preview-md h1{font-size:18px;font-weight:700;margin:16px 0 8px;color:#fff;border-bottom:1px solid var(--border);padding-bottom:6px;} + .preview-md h2{font-size:15px;font-weight:600;margin:14px 0 6px;color:#fff;} + .preview-md h3{font-size:13px;font-weight:600;margin:12px 0 4px;color:#e8e8f0;} + .preview-md ul,.preview-md ol{margin:4px 0 10px 18px;}.preview-md li{margin-bottom:3px;} + .preview-md code{font-family:"SF Mono",ui-monospace,monospace;font-size:11.5px;background:rgba(0,0,0,.35);padding:1px 5px;border-radius:4px;color:#f0c27f;} + .preview-md pre{background:var(--code-bg);border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:10px 12px;overflow-x:auto;margin:8px 0;} + .preview-md pre code{background:none;padding:0;color:#e2e8f0;font-size:11.5px;line-height:1.55;} + .preview-md blockquote{border-left:3px solid var(--blue);padding-left:12px;color:var(--muted);font-style:italic;margin:8px 0;} + .preview-md strong{color:#fff;font-weight:600;}.preview-md em{color:#c9c9e8;} + .preview-md a{color:var(--blue);text-decoration:underline;} + .preview-md hr{border:none;border-top:1px solid var(--border);margin:12px 0;} + .preview-md table{border-collapse:collapse;width:100%;margin:8px 0;font-size:12px;} + .preview-md th{background:rgba(255,255,255,.07);padding:6px 10px;text-align:left;font-weight:600;border:1px solid var(--border2);} + .preview-md td{padding:5px 10px;border:1px solid rgba(255,255,255,.06);} + .preview-md tr:nth-child(even){background:rgba(255,255,255,.03);} + /* File type badge in preview path bar */ + .preview-badge{display:inline-block;font-size:10px;font-weight:600;padding:2px 6px;border-radius:4px;margin-left:8px;text-transform:uppercase;letter-spacing:.06em;} + .preview-badge.img{background:rgba(124,185,255,.15);color:var(--blue);} + .preview-badge.md{background:rgba(201,168,76,.15);color:var(--gold);} + .preview-badge.code{background:rgba(255,255,255,.07);color:var(--muted);} + ::-webkit-scrollbar{width:4px;height:4px} + ::-webkit-scrollbar-track{background:transparent} + ::-webkit-scrollbar-thumb{background:rgba(255,255,255,.1);border-radius:99px;transition:background .2s} + ::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.22)} + @media(max-width:900px){.rightpanel{display:none}}@media(max-width:640px){.sidebar{display:none}} + +/* ── Workspace dropdown (topbar) ── */ +.ws-chip{user-select:none;} +.ws-dropdown{display:none;position:absolute;top:calc(100% + 6px);right:0;min-width:240px;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;} +.ws-dropdown.open{display:block;} +.ws-opt{padding:9px 14px;cursor:pointer;transition:background .12s;} +.ws-opt:hover{background:rgba(255,255,255,.07);} +.ws-opt.active{background:rgba(124,185,255,.1);} +.ws-opt-name{font-size:13px;color:var(--text);font-weight:500;} +.ws-opt-path{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} +.ws-divider{height:1px;background:var(--border);margin:4px 0;} +.ws-manage{color:var(--muted);font-size:12px;} +/* ── Workspace management panel ── */ +.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-info{flex:1;min-width:0;} +.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-actions{display:flex;gap:4px;flex-shrink:0;} +.ws-action-btn{padding:4px 9px;border-radius:6px;font-size:11px;font-weight:600;border:1px solid var(--border2);background:rgba(255,255,255,.05);color:var(--muted);cursor:pointer;transition:all .15s;white-space:nowrap;} +.ws-action-btn:hover{background:rgba(255,255,255,.1);color:var(--text);} +.ws-action-btn.danger:hover{background:rgba(233,69,96,.12);color:var(--accent);border-color:rgba(233,69,96,.3);} +.ws-add-row{display:flex;gap:8px;align-items:center;padding:10px 0 4px;} +/* ── Message action buttons (copy, edit, retry) ── */ +.msg-actions{display:flex;align-items:center;gap:2px;opacity:0;transition:opacity .15s;margin-left:auto;} +.msg-row:hover .msg-actions{opacity:1;} +.msg-action-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:5px;transition:color .12s,background .12s;line-height:1;} +.msg-action-btn:hover{color:var(--blue);background:rgba(124,185,255,.1);} + +/* ── 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-bar{display:flex;gap:8px;margin-top:8px;margin-bottom:4px;} +.msg-edit-send{background:var(--blue);color:#fff;border:none;border-radius:7px;padding:6px 16px;font-size:13px;font-weight:600;cursor:pointer;transition:opacity .15s;} +.msg-edit-send:hover{opacity:.85;} +.msg-edit-cancel{background:rgba(255,255,255,.06);color:var(--muted);border:1px solid var(--border2);border-radius:7px;padding:6px 12px;font-size:13px;cursor:pointer;transition:background .15s;} +.msg-edit-cancel:hover{background:rgba(255,255,255,.1);} + +/* ── Clear conversation chip ── */ +.clear-btn{background:rgba(201,168,76,.06);border:1px solid rgba(201,168,76,.18);color:var(--gold);font-size:11px;padding:4px 10px;cursor:pointer;transition:background .15s;} +.clear-btn:hover{background:rgba(201,168,76,.12);} + +/* ── Copy button on messages ── */ +/* msg-copy-btn styles moved to msg-action-btn */ +/* ── Nav tab nowrap ── */ +/* nav-tab-nowrap-handled-above */ + +/* ── Final polish additions ── */ + +/* Smooth hover on file items */ + + +/* Sidebar section padding: give the session-section breathing room */ +.sidebar-section{padding:10px 12px 6px;} + +/* New chat btn icon - align nicely */ +.new-chat-btn svg{flex-shrink:0;opacity:.8;} + +/* Session list: group header spacing */ +.session-list > div[style]{padding-left:12px;} + +/* Preview path bar: flex row with nice gap */ +.preview-path{display:flex;align-items:center;gap:6px;flex-wrap:nowrap;overflow:hidden;min-width:0;} +.preview-path #previewPathText{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} +.preview-path #previewBadge{flex-shrink:0;white-space:nowrap;} +.preview-path #btnDownloadFile,.preview-path #btnEditFile{flex-shrink:0;white-space:nowrap;} + +/* Preview badge typography */ +.preview-badge{font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;} + +/* Approval buttons: tab stops */ +.approval-btn:focus{outline:2px solid var(--blue);outline-offset:2px;} + +/* Message role: breathing room between icon and name */ +.msg-role > span{line-height:1;} + +/* Composer wrap: slightly less padding on smaller heights */ +.composer-wrap{border-top:1px solid rgba(255,255,255,.07);padding:10px 20px 14px;} + +/* Cron status badges: pill shape refinement */ +.cron-status{border-radius:99px;font-size:10px;letter-spacing:.04em;} + +/* Right panel icons: tighter */ +.panel-actions{gap:2px;} + +/* Workspace hint text: no intrusion */ +.sidebar-bottom > div[style*="topbar"]{pointer-events:none;} + +/* Topbar: border should match the subtle sidebar border */ +.topbar{border-bottom:1px solid rgba(255,255,255,.07);} + + + +/* Suggestion grid: consistent width */ +.suggestion-grid{width:100%;max-width:400px;} + +/* Empty state: add subtle gradient behind logo */ +.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) */ +.status-text{font-size:11px;color:var(--muted);padding-left:2px;display:none;} + +/* Sidebar workspace display */ +#sidebarWsDisplay:hover{background:rgba(255,255,255,.05);} +#sidebarWsDisplay:hover #sidebarWsName{color:var(--blue);} + +/* Date group headers in session list */ +.session-list > div[style*="uppercase"] { + padding: 8px 10px 3px !important; + font-size: 10px !important; +} +/* Sidebar bottom: tighten model field */ +.sidebar-bottom { padding: 10px 14px 12px; } +/* Right panel file tree: more padding for breathing room */ + +/* Composer footer: even spacing */ +.composer-footer { padding: 4px 10px 8px; } + +/* ── File tree: clean delete button via CSS hover ── */ +.file-del-btn{ + flex-shrink:0; + width:0;height:16px; + overflow:hidden; + background:none;border:none; + color:var(--muted);cursor:pointer; + font-size:13px;font-weight:300; + opacity:0; + transition:width .12s,opacity .12s,color .12s; + padding:0;border-radius:3px; + display:flex;align-items:center;justify-content:center; + line-height:1; +} +.file-item:hover .file-del-btn{ width:16px;opacity:1;margin-left:2px; } +.file-del-btn:hover{ color:var(--accent); } + +/* file-name must be a flex child that can shrink to zero */ +.file-name{ + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; + flex:1 1 0; + min-width:0; +} + +/* file-size: never wraps, shrinks away gracefully */ +.file-size{ + flex-shrink:0; + font-size:10px; + color:var(--muted); + white-space:nowrap; + margin-left:4px; + font-variant-numeric:tabular-nums; +} + +/* file-icon: never shrinks */ +.file-icon{ + flex-shrink:0; + font-size:13px; + opacity:.7; + line-height:1; +} + +/* ── Resizable panels ── */ +.resize-handle{ + position:absolute; + top:0;bottom:0; + width:5px; + cursor:col-resize; + z-index:10; + transition:background .15s; +} +.resize-handle:hover,.resize-handle.dragging{background:rgba(124,185,255,.35);} +.sidebar{position:relative;} +.sidebar .resize-handle{right:-2px;} +.rightpanel{position:relative;} +.rightpanel .resize-handle{left:-2px;} +/* Prevent text selection during drag */ +body.resizing{user-select:none;cursor:col-resize;} + +/* ── Tool call cards ── */ +/* Running indicator dot (pulsing) */ +.tool-card-running-dot{width:7px;height:7px;border-radius:50%;background:var(--blue);opacity:.8;flex-shrink:0;animation:pulse 1.2s ease-in-out infinite;} +/* Show more button inside tool card result */ +.tool-card-more{background:none;border:none;color:var(--blue);font-size:10px;cursor:pointer;padding:3px 0 0;opacity:.7;display:block;} +.tool-card-more:hover{opacity:1;} +.tool-card-row{margin:0;padding:1px 0;} +.tool-card{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:6px;margin:2px 0 2px 40px;overflow:hidden;transition:border-color .15s;} +.tool-card:hover{border-color:rgba(255,255,255,.12);} +.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-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-preview{font-size:11px;color:var(--muted);opacity:.6;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} +.tool-card-toggle{font-size:10px;color:var(--muted);opacity:.5;flex-shrink:0;transition:transform .15s;} +.tool-card.open .tool-card-toggle{transform:rotate(90deg);} +.tool-card-detail{display:none;border-top:1px solid rgba(255,255,255,.06);padding:8px 12px;} +.tool-card.open .tool-card-detail{display:block;} +.tool-card-args{margin-bottom:6px;} +.tool-card-args div{font-size:11px;line-height:1.6;} +.tool-arg-key{color:var(--blue);font-family:'SF Mono',ui-monospace,monospace;font-size:11px;} +.tool-arg-val{color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;font-size:11px;word-break:break-all;} +.tool-card-result pre{font-size:11px;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;max-height:180px;overflow-y:auto;margin:0;line-height:1.55;} + +/* ── Scrollbar polish ── */ +::-webkit-scrollbar{width:5px;height:5px;} +::-webkit-scrollbar-track{background:transparent;} +::-webkit-scrollbar-thumb{background:rgba(255,255,255,.12);border-radius:3px;} +::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.22);} +* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.12) transparent; } diff --git a/static/ui.js b/static/ui.js new file mode 100644 index 0000000..0d303f5 --- /dev/null +++ b/static/ui.js @@ -0,0 +1,589 @@ +const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null}; +const INFLIGHT={}; // keyed by session_id while request in-flight +const MSG_QUEUE=[]; // messages queued while a request is in-flight +const $=id=>document.getElementById(id); +const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + +function renderMd(raw){ + let s=raw||''; + s=s.replace(/```([\w+-]*)\n?([\s\S]*?)```/g,(_,lang,code)=>{const h=lang?`
${esc(lang)}
`:'';return `${h}
${esc(code.replace(/\n$/,''))}
`;}); + s=s.replace(/`([^`\n]+)`/g,(_,c)=>`${esc(c)}`); + s=s.replace(/\*\*\*(.+?)\*\*\*/g,'$1'); + s=s.replace(/\*\*(.+?)\*\*/g,'$1'); + s=s.replace(/\*([^*\n]+)\*/g,'$1'); + s=s.replace(/^### (.+)$/gm,'

$1

').replace(/^## (.+)$/gm,'

$1

').replace(/^# (.+)$/gm,'

$1

'); + s=s.replace(/^---+$/gm,'
'); + s=s.replace(/^> (.+)$/gm,'
$1
'); + // B8: improved list handling supporting up to 2 levels of indentation + s=s.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,block=>{ + const lines=block.trimEnd().split('\n'); + let html='
    '; + for(const l of lines){ + const indent=/^ {2,}/.test(l); + const text=l.replace(/^ {0,4}[-*+] /,''); + if(indent) html+=`
  • ${text}
  • `; + else html+=`
  • ${text}
  • `; + } + return html+'
'; + }); + s=s.replace(/((?:^(?: )?\d+\. .+\n?)+)/gm,block=>{ + const lines=block.trimEnd().split('\n'); + let html='
    '; + for(const l of lines){ + const text=l.replace(/^ {0,4}\d+\. /,''); + html+=`
  1. ${text}
  2. `; + } + return html+'
'; + }); + s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,'$1'); + // Tables: | col | col | header row followed by | --- | --- | separator then data rows + s=s.replace(/((?:^\|.+\|\n?)+)/gm,block=>{ + const rows=block.trim().split('\n').filter(r=>r.trim()); + if(rows.length<2)return block; + const isSep=r=>/^\|[\s|:-]+\|$/.test(r.trim()); + if(!isSep(rows[1]))return block; + const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`${c.trim()}`).join(''); + const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`${c.trim()}`).join(''); + const header=`${parseHeader(rows[0])}`; + const body=rows.slice(2).map(r=>`${parseRow(r)}`).join(''); + return `${header}${body}
`; + }); + const parts=s.split(/\n{2,}/); + s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)/.test(p))return p;return `

${p.replace(/\n/g,'
')}

`;}).join('\n'); + return s; +} + +function setStatus(t){ + const bar=$('activityBar'); + const txt=$('activityText'); + 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 === 'Hermes is thinking…'; + if(dismiss)dismiss.style.display=(!transient && !S.busy)?'inline':'none'; + } +} +function setBusy(v){ + S.busy=v; + $('btnSend').disabled=v; + const dots=$('activityDots'); + if(dots) dots.style.display=v?'flex':'none'; + if(!v){ + setStatus(''); + // Always hide Cancel button when not busy + const _cb=$('btnCancel');if(_cb)_cb.style.display='none'; + updateQueueBadge(); + // Drain one queued message after UI settles + if(MSG_QUEUE.length>0){ + const next=MSG_QUEUE.shift(); + updateQueueBadge(); + setTimeout(()=>{ $('msg').value=next; send(); }, 120); + } + } +} + +function updateQueueBadge(){ + let badge=$('queueBadge'); + if(MSG_QUEUE.length>0){ + if(!badge){ + badge=document.createElement('div'); + badge.id='queueBadge'; + badge.style.cssText='position:fixed;bottom:80px;right:24px;background:rgba(124,185,255,.18);border:1px solid rgba(124,185,255,.4);color:var(--blue);font-size:12px;font-weight:600;padding:6px 14px;border-radius:20px;z-index:50;pointer-events:none;backdrop-filter:blur(8px);'; + document.body.appendChild(badge); + } + badge.textContent=MSG_QUEUE.length===1?'1 message queued':`${MSG_QUEUE.length} messages queued`; + } else { + if(badge) badge.remove(); + } +} +function showToast(msg,ms){const el=$('toast');el.textContent=msg;el.classList.add('show');clearTimeout(el._t);el._t=setTimeout(()=>el.classList.remove('show'),ms||2800);} + +function copyMsg(btn){ + const row=btn.closest('.msg-row'); + const text=row?row.dataset.rawText:''; + if(!text)return; + navigator.clipboard.writeText(text).then(()=>{ + const orig=btn.innerHTML;btn.innerHTML='✓';btn.style.color='var(--blue)'; + setTimeout(()=>{btn.innerHTML=orig;btn.style.color='';},1500); + }).catch(()=>showToast('Copy failed')); +} + +// ── Reconnect banner (B4/B5: reload resilience) ── +const INFLIGHT_KEY = 'hermes-webui-inflight'; // localStorage key for in-flight session tracking + +function markInflight(sid, streamId) { + localStorage.setItem(INFLIGHT_KEY, JSON.stringify({sid, streamId, ts: Date.now()})); +} +function clearInflight() { + localStorage.removeItem(INFLIGHT_KEY); +} +function showReconnectBanner(msg) { + $('reconnectMsg').textContent = msg || 'A response may have been in progress when you last left.'; + $('reconnectBanner').classList.add('visible'); +} +function dismissReconnect() { + $('reconnectBanner').classList.remove('visible'); + clearInflight(); +} +async function refreshSession() { + dismissReconnect(); + if (!S.session) return; + try { + const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}`); + S.session = data.session; + S.messages = (data.session.messages || []).filter(m => { + if (!m || !m.role || m.role === 'tool') return false; + if (m.role === 'assistant') { let c = m.content || ''; if (Array.isArray(c)) c = c.map(p => p.text||'').join(''); return String(c).trim().length > 0; } + return true; + }); + syncTopbar(); renderMessages(); + showToast('Conversation refreshed'); + } catch(e) { setStatus('Refresh failed: ' + e.message); } +} +async function checkInflightOnBoot(sid) { + const raw = localStorage.getItem(INFLIGHT_KEY); + if (!raw) return; + try { + const {sid: inflightSid, streamId, ts} = JSON.parse(raw); + if (inflightSid !== sid) { clearInflight(); return; } + // Only show banner if the in-flight entry is less than 10 minutes old + if (Date.now() - ts > 10 * 60 * 1000) { clearInflight(); return; } + // Check if stream is still active + const status = await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId || '')}`); + if (status.active) { + // Stream is genuinely still running -- show the banner + showReconnectBanner('A response is still being generated. Reload when ready?'); + } else { + // Stream finished. Only show banner if reload happened within 90 seconds + // (longer gap = normal completed session, not a mid-stream reload) + if (Date.now() - ts < 90 * 1000) { + showReconnectBanner('A response was in progress when you last left. Messages may have updated.'); + } else { + clearInflight(); // completed normally, no banner needed + } + } + } catch(e) { clearInflight(); } +} + +function syncTopbar(){ + if(!S.session){ + // Show default workspace name even without a session + const sidebarName=$('sidebarWsName'); + if(sidebarName && sidebarName.textContent==='Workspace'){ + sidebarName.textContent='No workspace'; + } + return; + } + $('topbarTitle').textContent=S.session.title||'Untitled'; + const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool'); + $('topbarMeta').textContent=`${vis.length} messages`; + const m=S.session.model||''; + const MODEL_LABELS={'openai/gpt-5.4-mini':'GPT-5.4 Mini','openai/gpt-4o':'GPT-4o','openai/o3':'o3','openai/o4-mini':'o4-mini','anthropic/claude-sonnet-4.6':'Sonnet 4.6','anthropic/claude-sonnet-4-5':'Sonnet 4.5','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'}; + $('modelSelect').value=m; // set dropdown first so chip reads consistent value + // Show Clear button only when session has messages + const clearBtn=$('btnClearConv'); + if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(m=>m.role!=='tool').length>0)?'':'none'; + const displayModel=$('modelSelect').value||m; + $('modelChip').textContent=MODEL_LABELS[displayModel]||(displayModel.split('/').pop()||'Unknown'); + const ws=S.session.workspace||''; + $('wsChip').textContent=ws.split('/').slice(-2).join('/')||ws; + // Update workspace chip in topbar with friendly name from workspace list + const wsChipEl=$('wsChip'); + if(wsChipEl){ + const wsFriendly=getWorkspaceFriendlyName(ws); + wsChipEl.textContent='\u{1F4C1} '+wsFriendly+' \u25BE'; + } + // 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 +} + +function msgContent(m){ + // Extract plain text content from a message for filtering + let c=m.content||''; + if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('').trim(); + return String(c).trim(); +} + +function renderMessages(){ + const inner=$('msgInner'); + const vis=S.messages.filter(m=>{ + if(!m||!m.role||m.role==='tool')return false; + return msgContent(m)||m.attachments?.length; + }); + $('emptyState').style.display=vis.length?'none':''; + inner.innerHTML=''; + // Track original indices (in S.messages) so truncate knows the cut point + const visWithIdx=[]; + let rawIdx=0; + for(const m of S.messages){ + if(!m||!m.role||m.role==='tool'){rawIdx++;continue;} + if(msgContent(m)||m.attachments?.length) visWithIdx.push({m,rawIdx}); + rawIdx++; + } + for(let vi=0;vip&&p.type==='text').map(p=>p.text||p.content||'').join('\n'); + const isUser=m.role==='user'; + const isLastAssistant=!isUser&&vi===visWithIdx.length-1; + const row=document.createElement('div');row.className='msg-row'; + row.dataset.msgIdx=rawIdx; + let filesHtml=''; + if(m.attachments&&m.attachments.length) + filesHtml=`
${m.attachments.map(f=>`
📎 ${esc(f)}
`).join('')}
`; + const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'
') : renderMd(String(content)); + // Action buttons for this bubble + const editBtn = isUser ? `` : ''; + const retryBtn = isLastAssistant ? `` : ''; + row.innerHTML=`
${isUser?'Y':'H'}
${isUser?'You':'Hermes'}${editBtn}${retryBtn}
${filesHtml}
${bodyHtml}
`; + row.dataset.rawText = String(content).trim(); + inner.appendChild(row); + } + // Insert settled tool call cards (history view only). + // During live streaming, tool cards are rendered in #liveToolCards by the + // tool SSE handler and never mixed into the message list until done fires. + if(!S.busy && S.toolCalls && S.toolCalls.length){ + inner.querySelectorAll('.tool-card-row').forEach(el=>el.remove()); + const byAssistant = {}; + for(const tc of S.toolCalls){ + const key = tc.assistant_msg_idx !== undefined ? tc.assistant_msg_idx : -1; + if(!byAssistant[key]) byAssistant[key] = []; + byAssistant[key].push(tc); + } + const allRows = Array.from(inner.querySelectorAll('.msg-row[data-msg-idx]')); + for(const [key, cards] of Object.entries(byAssistant)){ + const aIdx = parseInt(key); + let insertBefore = null; + if(aIdx === -1){ + for(let i=allRows.length-1;i>=0;i--){ + const ri=parseInt(allRows[i].dataset.msgIdx||'-1',10); + if(ri>=0&&S.messages[ri]&&S.messages[ri].role==='assistant'){insertBefore=allRows[i];break;} + } + } else { + for(const r of allRows){ + const ri=parseInt(r.dataset.msgIdx||'-1'); + if(ri>aIdx&&S.messages[ri]&&S.messages[ri].role==='assistant'){insertBefore=r;break;} + } + } + const frag=document.createDocumentFragment(); + for(const tc of cards){frag.appendChild(buildToolCard(tc));} + if(insertBefore) inner.insertBefore(frag,insertBefore); + else inner.appendChild(frag); + } + } + $('messages').scrollTop=$('messages').scrollHeight; + // Apply syntax highlighting after DOM is built + requestAnimationFrame(()=>highlightCode()); + // Refresh todo panel if it's currently open + if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){ + loadTodos(); + } +} + +function toolIcon(name){ + const icons={terminal:'⬛',read_file:'📄',write_file:'✏️',search_files:'🔍', + web_search:'🌐',web_extract:'🌐',execute_code:'⚙️',patch:'🔧', + memory:'🧠',skill_manage:'📚',todo:'✅',cronjob:'⏱️',delegate_task:'🤖', + send_message:'💬',browser_navigate:'🌐',vision_analyze:'👁️'}; + return icons[name]||'🔧'; +} + +function buildToolCard(tc){ + const row=document.createElement('div'); + row.className='msg-row tool-card-row'; + const icon=toolIcon(tc.name); + const hasDetail=tc.snippet||(tc.args&&Object.keys(tc.args).length>0); + let displaySnippet=''; + if(tc.snippet){ + const s=tc.snippet; + if(s.length<=220){displaySnippet=s;} + else{ + const cutoff=s.slice(0,220); + const lastBreak=Math.max(cutoff.lastIndexOf('. '),cutoff.lastIndexOf('\n'),cutoff.lastIndexOf('; ')); + displaySnippet=lastBreak>80?s.slice(0,lastBreak+1):cutoff; + } + } + const hasMore=tc.snippet&&tc.snippet.length>displaySnippet.length; + const runIndicator=tc.done===false?'':''; + row.innerHTML=` +
+
+ ${runIndicator} + ${icon} + ${esc(tc.name)} + ${esc(tc.preview||displaySnippet||'')} + ${hasDetail?'':''} +
+ ${hasDetail?`
+ ${tc.args&&Object.keys(tc.args).length?`
${ + Object.entries(tc.args).map(([k,v])=>`
${esc(k)} ${esc(String(v))}
`).join('') + }
`:''} + ${displaySnippet?`
+
${esc(displaySnippet)}
+ ${hasMore?``:''} +
`:''} +
`:''} +
`; + return row; +} + +// ── Live tool card helpers (called during SSE streaming) ── +function appendLiveToolCard(tc){ + const container=$('liveToolCards'); + if(!container)return; + container.style.display=''; + // Update existing card if same tool call id (e.g. snippet arrives after done) + const existing=container.querySelector(`[data-tid="${CSS.escape(tc.tid||'')}"]`); + if(existing){existing.replaceWith(buildToolCard(tc));return;} + const card=buildToolCard(tc); + if(tc.tid)card.dataset.tid=tc.tid; + container.appendChild(card); +} + +function clearLiveToolCards(){ + const container=$('liveToolCards'); + if(!container)return; + container.innerHTML=''; + container.style.display='none'; +} + +// ── Edit + Regenerate ── + +function editMessage(btn) { + if(S.busy) return; + const row = btn.closest('.msg-row'); + if(!row) return; + const msgIdx = parseInt(row.dataset.msgIdx, 10); + const originalText = row.dataset.rawText || ''; + const body = row.querySelector('.msg-body'); + if(!body || row.dataset.editing) return; + row.dataset.editing = '1'; + + // Replace msg-body with an editable textarea + const ta = document.createElement('textarea'); + ta.className = 'msg-edit-area'; + ta.value = originalText; + body.replaceWith(ta); + // Resize after DOM insertion so scrollHeight is correct + requestAnimationFrame(() => { autoResizeTextarea(ta); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); }); + ta.addEventListener('input', () => autoResizeTextarea(ta)); + + // Action bar below the textarea + const bar = document.createElement('div'); + bar.className = 'msg-edit-bar'; + bar.innerHTML = ``; + ta.after(bar); + + bar.querySelector('.msg-edit-send').onclick = async () => { + const newText = ta.value.trim(); + if(!newText) return; + await submitEdit(msgIdx, newText); + }; + bar.querySelector('.msg-edit-cancel').onclick = () => cancelEdit(row, originalText, body); + + ta.addEventListener('keydown', e => { + if(e.key==='Enter' && !e.shiftKey) { e.preventDefault(); bar.querySelector('.msg-edit-send').click(); } + if(e.key==='Escape') { e.preventDefault(); cancelEdit(row, originalText, body); } + }); +} + +function cancelEdit(row, originalText, originalBody) { + delete row.dataset.editing; + const ta = row.querySelector('.msg-edit-area'); + const bar = row.querySelector('.msg-edit-bar'); + if(ta) ta.replaceWith(originalBody); + if(bar) bar.remove(); +} + +function autoResizeTextarea(ta) { + ta.style.height = 'auto'; + ta.style.height = Math.min(ta.scrollHeight, 300) + 'px'; +} + +async function submitEdit(msgIdx, newText) { + if(!S.session || S.busy) return; + // Truncate session at msgIdx (keep messages before the edited one) + // then re-send the edited text + try { + await api('/api/session/truncate', {method:'POST', body:JSON.stringify({ + session_id: S.session.session_id, + keep_count: msgIdx // keep messages[0..msgIdx-1], discard from msgIdx onward + })}); + S.messages = S.messages.slice(0, msgIdx); + renderMessages(); + // Now send the edited message as a new chat + $('msg').value = newText; + await send(); + } catch(e) { setStatus('Edit failed: ' + e.message); } +} + +async function regenerateResponse(btn) { + if(!S.session || S.busy) return; + // Find the last user message and re-run it + // Remove the last assistant message first (truncate to before it) + const row = btn.closest('.msg-row'); + if(!row) return; + const assistantIdx = parseInt(row.dataset.msgIdx, 10); + // Find the last user message text (one before this assistant message) + let lastUserText = ''; + for(let i = assistantIdx - 1; i >= 0; i--) { + const m = S.messages[i]; + if(m && m.role === 'user') { lastUserText = msgContent(m); break; } + } + if(!lastUserText) return; + try { + await api('/api/session/truncate', {method:'POST', body:JSON.stringify({ + session_id: S.session.session_id, + keep_count: assistantIdx // remove the assistant message + })}); + S.messages = S.messages.slice(0, assistantIdx); + renderMessages(); + $('msg').value = lastUserText; + await send(); + } catch(e) { setStatus('Regenerate failed: ' + e.message); } +} + +function highlightCode(container) { + // Apply Prism.js syntax highlighting to all code blocks in container (or whole messages area) + if(typeof Prism === 'undefined' || !Prism.highlightAllUnder) return; + const el = container || $('msgInner'); + if(!el) return; + // Prism autoloader handles language detection via class="language-xxx" + Prism.highlightAllUnder(el); +} + +function appendThinking(){ + $('emptyState').style.display='none'; + const row=document.createElement('div');row.className='msg-row';row.id='thinkingRow'; + row.innerHTML=`
H
Hermes
`; + $('msgInner').appendChild(row);$('messages').scrollTop=$('messages').scrollHeight; +} +function removeThinking(){const el=$('thinkingRow');if(el)el.remove();} + +function fileIcon(name, type){ + if(type==='dir') return '📁'; + const e=fileExt(name); + if(IMAGE_EXTS.has(e)) return '📷'; + if(MD_EXTS.has(e)) return '📝'; + if(typeof DOWNLOAD_EXTS!=='undefined'&&DOWNLOAD_EXTS.has(e)) return '⬇️'; + if(e==='.py') return '🐍'; + if(e==='.js'||e==='.ts'||e==='.jsx'||e==='.tsx') return '⚡'; + if(e==='.json'||e==='.yaml'||e==='.yml'||e==='.toml') return '⚙'; + if(e==='.sh'||e==='.bash') return '💻'; + if(e==='.pdf') return '⬇️'; + return '📄'; +} + +function renderFileTree(){ + const box=$('fileTree');box.innerHTML=''; + for(const item of S.entries){ + const el=document.createElement('div');el.className='file-item'; + + // Icon + const iconEl=document.createElement('span'); + iconEl.className='file-icon';iconEl.textContent=fileIcon(item.name,item.type); + el.appendChild(iconEl); + + // Name -- takes all remaining space, truncates with ellipsis + const nameEl=document.createElement('span'); + nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title=item.name; + el.appendChild(nameEl); + + // Size -- only for files, right-aligned, shrinks but never wraps + if(item.type==='file'&&item.size){ + const sizeEl=document.createElement('span'); + sizeEl.className='file-size'; + sizeEl.textContent=`${(item.size/1024).toFixed(1)}k`; + el.appendChild(sizeEl); + } + + // Delete button -- only for files, shown as a CSS class toggle on hover + if(item.type==='file'){ + const del=document.createElement('button'); + del.className='file-del-btn';del.title='Delete';del.textContent='×'; + del.onclick=async(e)=>{e.stopPropagation();await deleteWorkspaceFile(item.path,item.name);}; + el.appendChild(del); + } + + el.onclick=async()=>item.type==='dir'?loadDir(item.path):openFile(item.path); + box.appendChild(el); + } +} + +async function deleteWorkspaceFile(relPath, name){ + if(!S.session)return; + if(!confirm(`Delete ${name}?`))return; + try{ + await api('/api/file/delete',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath})}); + showToast(`Deleted ${name}`); + // Close preview if we just deleted the viewed file + if($('previewPathText').textContent===relPath)$('btnClearPreview').onclick(); + await loadDir('.'); + }catch(e){setStatus('Delete failed: '+e.message);} +} + +async function promptNewFile(){ + if(!S.session)return; + const name=prompt('New file name (e.g. notes.md):',''); + if(!name||!name.trim())return; + try{ + await api('/api/file/create',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:name.trim(),content:''})}); + showToast(`Created ${name.trim()}`); + await loadDir('.'); + // Open the new file immediately + openFile(name.trim()); + }catch(e){setStatus('Create failed: '+e.message);} +} + +function renderTray(){ + const tray=$('attachTray');tray.innerHTML=''; + if(!S.pendingFiles.length){tray.classList.remove('has-files');return;} + tray.classList.add('has-files'); + S.pendingFiles.forEach((f,i)=>{ + const chip=document.createElement('div');chip.className='attach-chip'; + chip.innerHTML=`📎 ${esc(f.name)} `; + chip.querySelector('button').onclick=()=>{S.pendingFiles.splice(i,1);renderTray();}; + tray.appendChild(chip); + }); +} +function addFiles(files){for(const f of files){if(!S.pendingFiles.find(p=>p.name===f.name))S.pendingFiles.push(f);}renderTray();} + +async function uploadPendingFiles(){ + if(!S.pendingFiles.length||!S.session)return[]; + const names=[];let failures=0; + const bar=$('uploadBar');const barWrap=$('uploadBarWrap'); + barWrap.classList.add('active');bar.style.width='0%'; + const total=S.pendingFiles.length; + for(let i=0;i0)throw new Error(`All ${total} upload(s) failed`); + return names; +} + diff --git a/static/workspace.js b/static/workspace.js new file mode 100644 index 0000000..9290871 --- /dev/null +++ b/static/workspace.js @@ -0,0 +1,168 @@ +async function api(path,opts={}){ + const res=await fetch(path,{headers:{'Content-Type':'application/json'},...opts}); + if(!res.ok)throw new Error(await res.text()); + const ct=res.headers.get('content-type')||''; + return ct.includes('application/json')?res.json():res.text(); +} + +async function loadDir(path){ + if(!S.session)return; + try{ + const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`); + S.entries=data.entries||[];renderFileTree(); + }catch(e){console.warn('loadDir',e);} +} + +// File extension sets for preview routing (must match server-side sets) +const IMAGE_EXTS = new Set(['.png','.jpg','.jpeg','.gif','.svg','.webp','.ico','.bmp']); +const MD_EXTS = new Set(['.md','.markdown','.mdown']); +// Binary formats that should download rather than preview +const DOWNLOAD_EXTS = new Set([ + '.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp', + '.pdf','.zip','.tar','.gz','.bz2','.7z','.rar', + '.mp3','.mp4','.wav','.m4a','.ogg','.flac','.mov','.avi','.mkv','.webm', + '.exe','.dmg','.pkg','.deb','.rpm', + '.woff','.woff2','.ttf','.otf','.eot', + '.bin','.dat','.db','.sqlite','.pyc','.class','.so','.dylib','.dll', +]); + +function fileExt(p){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; } + +let _previewCurrentPath = ''; // relative path of currently previewed file +let _previewCurrentMode = ''; // 'code' | 'md' | 'image' +let _previewDirty = false; // true when edits are unsaved + +function showPreview(mode){ + // mode: 'code' | 'image' | 'md' + $('previewCode').style.display = mode==='code' ? '' : 'none'; + $('previewImgWrap').style.display = mode==='image' ? '' : 'none'; + $('previewMd').style.display = mode==='md' ? '' : 'none'; + $('previewEditArea').style.display = 'none'; // start in read-only + const badge=$('previewBadge'); + badge.className='preview-badge '+mode; + badge.textContent = mode==='image'?'image':mode==='md'?'md':fileExt($('previewPathText').textContent)||'text'; + _previewCurrentMode = mode; + _previewDirty = false; + updateEditBtn(); +} + +function updateEditBtn(){ + const btn=$('btnEditFile'); + if(!btn)return; + const editable = _previewCurrentMode==='code'||_previewCurrentMode==='md'; + btn.style.display = editable?'':'none'; + const editing = $('previewEditArea').style.display!=='none'; + btn.innerHTML = editing ? '💾 Save' : '✎ Edit'; + btn.title = editing ? 'Save changes' : 'Edit this file'; + btn.style.color = editing ? 'var(--blue)' : ''; + if(_previewDirty) btn.innerHTML = '💾 Save*'; +} + +async function toggleEditMode(){ + const editing = $('previewEditArea').style.display!=='none'; + if(editing){ + // Save + if(!S.session||!_previewCurrentPath)return; + const content=$('previewEditArea').value; + try{ + await api('/api/file/save',{method:'POST',body:JSON.stringify({ + session_id:S.session.session_id, path:_previewCurrentPath, content + })}); + _previewDirty=false; + // Update read-only views + if(_previewCurrentMode==='code') $('previewCode').textContent=content; + else $('previewMd').innerHTML=renderMd(content); + $('previewEditArea').style.display='none'; + if(_previewCurrentMode==='code') $('previewCode').style.display=''; + else $('previewMd').style.display=''; + showToast('Saved'); + }catch(e){setStatus('Save failed: '+e.message);} + }else{ + // Enter edit mode: populate textarea with current content + const currentText = _previewCurrentMode==='code' + ? $('previewCode').textContent + : _previewRawContent||''; + $('previewEditArea').value=currentText; + $('previewEditArea').style.display=''; + if(_previewCurrentMode==='code') $('previewCode').style.display='none'; + else $('previewMd').style.display='none'; + // Escape cancels the edit without saving + $('previewEditArea').onkeydown=e=>{ + if(e.key==='Escape'){e.preventDefault();cancelEditMode();} + }; + } + updateEditBtn(); +} + +let _previewRawContent = ''; // raw text for md files (to populate editor) + +function cancelEditMode(){ + // Discard changes and return to read-only view + $('previewEditArea').style.display='none'; + $('previewEditArea').onkeydown=null; + if(_previewCurrentMode==='code') $('previewCode').style.display=''; + else $('previewMd').style.display=''; + _previewDirty=false; + updateEditBtn(); +} + +async function openFile(path){ + if(!S.session)return; + const ext=fileExt(path); + + // Binary/download-only formats: trigger browser download, don't preview + if(DOWNLOAD_EXTS.has(ext)){ + downloadFile(path); + return; + } + + $('previewPathText').textContent=path; + $('previewArea').classList.add('visible'); + $('fileTree').style.display='none'; + + _previewCurrentPath = path; + if(IMAGE_EXTS.has(ext)){ + // Image: load via raw endpoint, show as + showPreview('image'); + const url=`/api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`; + $('previewImg').alt=path; + $('previewImg').src=url; + $('previewImg').onerror=()=>setStatus('Could not load image'); + } else if(MD_EXTS.has(ext)){ + // Markdown: fetch text, render with renderMd, display as formatted HTML + try{ + const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`); + showPreview('md'); + _previewRawContent = data.content; + $('previewMd').innerHTML=renderMd(data.content); + }catch(e){setStatus('Could not open file');} + } else { + // Plain code / text -- but fall back to download if server signals binary + try{ + const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`); + if(data.binary){ + // Server flagged this as binary content + downloadFile(path); + return; + } + showPreview('code'); + $('previewCode').textContent=data.content; + }catch(e){ + // If it's a 400/too-large error, offer download instead + downloadFile(path); + } + } +} + +function downloadFile(path){ + if(!S.session)return; + // Trigger browser download via the raw file endpoint with content-disposition attachment + const url=`/api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`; + const filename=path.split('/').pop(); + const a=document.createElement('a'); + a.href=url;a.download=filename; + document.body.appendChild(a);a.click(); + setTimeout(()=>document.body.removeChild(a),100); + showToast(`Downloading ${filename}\u2026`,2000); +} + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5d53f4a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,240 @@ +""" +Shared pytest fixtures for webui-mvp tests. + +TEST ISOLATION: + Tests run against a SEPARATE server instance on port 8788 with a + completely separate state directory. Production data is never touched. + The test state dir is wiped before each full test run and again on teardown. + +PATH DISCOVERY: + No hardcoded paths. Discovery order: + 1. Environment variables (HERMES_WEBUI_AGENT_DIR, HERMES_WEBUI_PYTHON, etc.) + 2. Sibling checkout heuristics relative to this repo + 3. Common install paths (~/.hermes/hermes-agent) + 4. System python3 as a last resort +""" +import json +import os +import pathlib +import shutil +import subprocess +import time +import urllib.request +import urllib.error +import pytest + +# ── Repo root discovery ──────────────────────────────────────────────────── +# conftest.py lives at /tests/conftest.py +TESTS_DIR = pathlib.Path(__file__).parent.resolve() +REPO_ROOT = TESTS_DIR.parent.resolve() +HOME = pathlib.Path.home() +HERMES_HOME = pathlib.Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))) + +# ── Test server config ──────────────────────────────────────────────────── +TEST_PORT = int(os.getenv('HERMES_WEBUI_TEST_PORT', '8788')) +TEST_BASE = f"http://127.0.0.1:{TEST_PORT}" +TEST_STATE_DIR = pathlib.Path(os.getenv( + 'HERMES_WEBUI_TEST_STATE_DIR', + str(HERMES_HOME / 'webui-mvp-test') +)) +TEST_WORKSPACE = TEST_STATE_DIR / 'test-workspace' + +# ── Server script: always relative to repo root ─────────────────────────── +SERVER_SCRIPT = REPO_ROOT / 'server.py' +if not SERVER_SCRIPT.exists(): + raise RuntimeError( + f"server.py not found at {SERVER_SCRIPT}. " + "Is conftest.py in the tests/ subdirectory of the repo?" + ) + +# ── Hermes agent discovery (mirrors api/config._discover_agent_dir) ─────── +def _discover_agent_dir() -> pathlib.Path: + candidates = [ + os.getenv('HERMES_WEBUI_AGENT_DIR', ''), + str(HERMES_HOME / 'hermes-agent'), + str(REPO_ROOT.parent / 'hermes-agent'), + str(HOME / '.hermes' / 'hermes-agent'), + str(HOME / 'hermes-agent'), + ] + for c in candidates: + if not c: + continue + p = pathlib.Path(c).expanduser() + if p.exists() and (p / 'run_agent.py').exists(): + return p.resolve() + return None + +# ── Python discovery (mirrors api/config._discover_python) ──────────────── +def _discover_python(agent_dir) -> str: + if os.getenv('HERMES_WEBUI_PYTHON'): + return os.getenv('HERMES_WEBUI_PYTHON') + if agent_dir: + venv_py = agent_dir / 'venv' / 'bin' / 'python' + if venv_py.exists(): + return str(venv_py) + local_venv = REPO_ROOT / '.venv' / 'bin' / 'python' + if local_venv.exists(): + return str(local_venv) + return shutil.which('python3') or shutil.which('python') or 'python3' + +HERMES_AGENT = _discover_agent_dir() +VENV_PYTHON = _discover_python(HERMES_AGENT) + +# Work dir: agent dir if found, else repo root +WORKDIR = str(HERMES_AGENT) if HERMES_AGENT else str(REPO_ROOT) + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +def _post(base, path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request( + base + path, data=data, headers={"Content-Type": "application/json"} + ) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()) + except urllib.error.HTTPError as e: + try: + return json.loads(e.read()) + except Exception: + return {} + + +def _wait_for_server(base, timeout=20): + deadline = time.time() + timeout + while time.time() < deadline: + try: + with urllib.request.urlopen(base + "/health", timeout=2) as r: + if json.loads(r.read()).get("status") == "ok": + return True + except Exception: + time.sleep(0.3) + return False + + +# ── Session-scoped test server ──────────────────────────────────────────────── + +@pytest.fixture(scope="session", autouse=True) +def test_server(): + """ + Start an isolated test server on TEST_PORT with a clean state directory. + Paths are discovered dynamically -- no hardcoded absolute path assumptions. + """ + # Clean slate + if TEST_STATE_DIR.exists(): + shutil.rmtree(TEST_STATE_DIR) + TEST_STATE_DIR.mkdir(parents=True) + TEST_WORKSPACE.mkdir(parents=True) + + # Symlink real skills into test home so skill-related tests work, + # but all write-heavy state stays isolated. + real_skills = HERMES_HOME / 'skills' + test_skills = TEST_STATE_DIR / 'skills' + if real_skills.exists() and not test_skills.exists(): + test_skills.symlink_to(real_skills) + + # Isolated cron state + (TEST_STATE_DIR / 'cron').mkdir(parents=True, exist_ok=True) + + env = os.environ.copy() + env.update({ + "HERMES_WEBUI_PORT": str(TEST_PORT), + "HERMES_WEBUI_HOST": "127.0.0.1", + "HERMES_WEBUI_STATE_DIR": str(TEST_STATE_DIR), + "HERMES_WEBUI_DEFAULT_WORKSPACE": str(TEST_WORKSPACE), + "HERMES_WEBUI_DEFAULT_MODEL": "openai/gpt-5.4-mini", + "HERMES_HOME": str(TEST_STATE_DIR), + }) + + # Pass agent dir if discovered so server.py doesn't have to re-discover + if HERMES_AGENT: + env["HERMES_WEBUI_AGENT_DIR"] = str(HERMES_AGENT) + + proc = subprocess.Popen( + [VENV_PYTHON, str(SERVER_SCRIPT)], + cwd=WORKDIR, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + if not _wait_for_server(TEST_BASE, timeout=20): + proc.kill() + pytest.fail( + f"Test server on port {TEST_PORT} did not start within 20s.\n" + f" server.py : {SERVER_SCRIPT}\n" + f" python : {VENV_PYTHON}\n" + f" agent dir : {HERMES_AGENT}\n" + f" workdir : {WORKDIR}\n" + ) + + yield proc + + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + + try: + shutil.rmtree(TEST_STATE_DIR) + except Exception: + pass + + +# ── Test base URL ───────────────────────────────────────────────────────────── + +@pytest.fixture(scope="session") +def base_url(): + return TEST_BASE + + +# ── Per-test session cleanup ────────────────────────────────────────────────── + +@pytest.fixture(autouse=True) +def cleanup_test_sessions(): + """ + Yields a list for tests to register created session IDs. + Deletes all registered sessions after each test. + Resets last_workspace to the test workspace to prevent state bleed. + """ + created: list[str] = [] + yield created + + for sid in created: + try: + _post(TEST_BASE, "/api/session/delete", {"session_id": sid}) + except Exception: + pass + + try: + _post(TEST_BASE, "/api/sessions/cleanup_zero_message") + except Exception: + pass + + try: + last_ws_file = TEST_STATE_DIR / "last_workspace.txt" + last_ws_file.write_text(str(TEST_WORKSPACE), encoding='utf-8') + except Exception: + pass + + +# ── Convenience helpers ──────────────────────────────────────────────────────── + +def make_session_tracked(created_list, ws=None): + """ + Create a session on the test server and register it for cleanup. + + Usage: + def test_something(cleanup_test_sessions): + sid, ws = make_session_tracked(cleanup_test_sessions) + """ + body = {} + if ws: + body["workspace"] = str(ws) + d = _post(TEST_BASE, "/api/session/new", body) + sid = d["session"]["session_id"] + ws_path = pathlib.Path(d["session"]["workspace"]) + created_list.append(sid) + return sid, ws_path diff --git a/tests/test_regressions.py b/tests/test_regressions.py new file mode 100644 index 0000000..3173cf1 --- /dev/null +++ b/tests/test_regressions.py @@ -0,0 +1,416 @@ +""" +Regression tests -- one test per bug that was introduced and fixed. +These tests exist specifically to prevent those bugs from silently returning. + +Each test is tagged with the sprint/commit where the bug was found and fixed. +""" +import json +import pathlib +import time +import urllib.error +import urllib.request +import urllib.parse +REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve() + +BASE = "http://127.0.0.1:8788" + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + +def get_raw(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return r.read(), r.headers.get("Content-Type",""), r.status + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request( + BASE + path, data=data, headers={"Content-Type": "application/json"} + ) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + +def make_session(created_list): + d, _ = post("/api/session/new", {}) + sid = d["session"]["session_id"] + created_list.append(sid) + return sid + + +# ── R1: uuid not imported in server.py (Sprint 10 split regression) ────────── + +def test_chat_start_returns_stream_id(cleanup_test_sessions): + """R1: chat/start must return stream_id -- catches missing uuid import. + When uuid was missing, this returned 500 (NameError). + """ + sid = make_session(cleanup_test_sessions) + data, status = post("/api/chat/start", { + "session_id": sid, + "message": "ping", + "model": "openai/gpt-5.4-mini", + }) + # Must return 200 with a stream_id -- not 500 + assert status == 200, f"chat/start failed with {status}: {data}" + assert "stream_id" in data, "stream_id missing from chat/start response" + assert len(data["stream_id"]) > 8, "stream_id looks invalid" + post("/api/session/delete", {"session_id": sid}) + cleanup_test_sessions.clear() + + +# ── R2: AIAgent not imported in api/streaming.py (Sprint 10 split regression) ─ + +def test_chat_stream_opens_successfully(cleanup_test_sessions): + """R2: After chat/start, GET /api/chat/stream must return 200 (SSE opens). + When AIAgent was missing, the thread crashed immediately, popped STREAMS, + and the SSE GET returned 404. + """ + sid = make_session(cleanup_test_sessions) + data, status = post("/api/chat/start", { + "session_id": sid, + "message": "say: hello", + "model": "openai/gpt-5.4-mini", + }) + assert status == 200, f"chat/start failed: {data}" + stream_id = data["stream_id"] + + # Open the SSE stream -- must return 200, not 404 + # We only check headers (don't read the full stream body) + req = urllib.request.Request(BASE + f"/api/chat/stream?stream_id={stream_id}") + try: + r = urllib.request.urlopen(req, timeout=3) + assert r.status == 200, f"SSE stream returned {r.status} (expected 200)" + ct = r.headers.get("Content-Type", "") + assert "text/event-stream" in ct, f"Wrong Content-Type: {ct}" + r.close() + except urllib.error.HTTPError as e: + assert False, f"SSE stream returned {e.code} -- AIAgent may not be imported" + except Exception: + pass # timeout or connection close after brief read is fine + + post("/api/session/delete", {"session_id": sid}) + cleanup_test_sessions.clear() + + +# ── R3: Session.__init__ missing tool_calls param (Sprint 10 split regression) ─ + +def test_session_with_tool_calls_in_json_loads_ok(cleanup_test_sessions): + """R3: Sessions that have tool_calls in their JSON must load without 500. + When tool_calls=None was missing from Session.__init__, loading such sessions + threw TypeError: unexpected keyword argument. + """ + sid = make_session(cleanup_test_sessions) + + # Manually inject tool_calls into the session's JSON file + sessions_dir = pathlib.Path.home() / ".hermes" / "webui-mvp-test" / "sessions" + session_file = sessions_dir / f"{sid}.json" + if session_file.exists(): + d = json.loads(session_file.read_text()) + d["tool_calls"] = [ + {"name": "terminal", "snippet": "test output", "tid": "test_tid_001", "assistant_msg_idx": 1} + ] + session_file.write_text(json.dumps(d)) + + # Loading the session must return 200, not 500 + data, status = get(f"/api/session?session_id={urllib.parse.quote(sid)}") + assert status == 200, f"Session with tool_calls returned {status}: {data}" + assert data["session"]["session_id"] == sid + + post("/api/session/delete", {"session_id": sid}) + cleanup_test_sessions.clear() + + +# ── R4: has_pending not imported in streaming.py (Sprint 10 split regression) ─ + +def test_streaming_py_imports_has_pending(cleanup_test_sessions): + """R4: api/streaming.py must import or define has_pending. + When missing, the approval check mid-stream caused NameError. + """ + src = (REPO_ROOT / "api/streaming.py").read_text() + assert "has_pending" in src, "has_pending not found in api/streaming.py" + # Verify it's imported (not just used) + assert "import" in src and "has_pending" in src, \ + "has_pending must be imported in api/streaming.py" + + +def test_aiagent_imported_in_streaming(cleanup_test_sessions): + """R2b: api/streaming.py must import AIAgent. + When missing, the streaming thread crashed immediately after being spawned. + """ + src = (REPO_ROOT / "api/streaming.py").read_text() + assert "AIAgent" in src, "AIAgent not referenced in api/streaming.py" + assert "from run_agent import AIAgent" in src or "import AIAgent" in src, \ + "AIAgent must be imported in api/streaming.py" + + +# ── R5: SSE loop did not break on cancel event (Sprint 10 bug) ─────────────── + +def test_cancel_nonexistent_stream_returns_not_cancelled(cleanup_test_sessions): + """R5a: Cancel endpoint works and returns cancelled:false for unknown stream.""" + data, status = get("/api/chat/cancel?stream_id=nonexistent_test_xyz") + assert status == 200 + assert data["ok"] is True + assert data["cancelled"] is False + + +def test_server_py_sse_loop_breaks_on_cancel(cleanup_test_sessions): + """R5b: server.py SSE loop must include 'cancel' in the break condition. + When missing, the connection hung after the cancel event was processed. + """ + src = (REPO_ROOT / "server.py").read_text() + # Find the SSE break condition + import re + m = re.search(r"if event in \([^)]+\):\s*break", src) + assert m, "SSE break condition not found in server.py" + assert "cancel" in m.group(), \ + f"'cancel' missing from SSE break condition: {m.group()}" + + +# ── R6: Test cron isolation (Sprint 10) ────────────────────────────────────── + +def test_real_jobs_json_not_polluted_by_tests(cleanup_test_sessions): + """R6: Test runs must not write to the real ~/.hermes/cron/jobs.json. + When HERMES_HOME isolation was missing, every test run added test-job-* entries. + """ + real_jobs_path = pathlib.Path.home() / ".hermes" / "cron" / "jobs.json" + if not real_jobs_path.exists(): + return # no jobs file at all -- fine + + jobs = json.loads(real_jobs_path.read_text()) + if isinstance(jobs, dict): + jobs = jobs.get("jobs", []) + + test_jobs = [j for j in jobs if j.get("name", "").startswith("test-job-")] + assert len(test_jobs) == 0, \ + f"Real jobs.json contains {len(test_jobs)} test-job-* entries: " \ + f"{[j['name'] for j in test_jobs]}" + + +# ── General: api modules all importable ────────────────────────────────────── + +def test_all_api_modules_importable(cleanup_test_sessions): + """All api/ modules must be importable without NameError or ImportError. + Catches missing imports introduced during future module splits. + """ + import ast, pathlib + api_dir = REPO_ROOT / "api" + for module_file in api_dir.glob("*.py"): + src = module_file.read_text() + try: + ast.parse(src) + except SyntaxError as e: + assert False, f"{module_file.name} has syntax error: {e}" + + +def test_server_py_importable(cleanup_test_sessions): + """server.py must parse without syntax errors after any split.""" + import ast, pathlib + src = (REPO_ROOT / "server.py").read_text() + try: + ast.parse(src) + except SyntaxError as e: + assert False, f"server.py has syntax error: {e}" + +# ── R7: Cross-session busy state bleed ─────────────────────────────────────── + +def test_loadSession_resets_busy_state_for_idle_session(cleanup_test_sessions): + """R7: sessions.js loadSession for a non-inflight session must reset S.busy to false. + When missing, switching from a busy session to an idle one left the Send button + disabled, showed the wrong activity bar, and pointed Cancel at the wrong stream. + """ + src = (REPO_ROOT / "static/sessions.js").read_text() + # 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" + # btnSend must be explicitly re-enabled + assert "$('btnSend').disabled=false;" in src, "sessions.js loadSession must enable btnSend for non-inflight sessions" + + +def test_done_handler_guards_setbusy_with_inflight_check(cleanup_test_sessions): + """R7b: messages.js done/error handlers must not call setBusy(false) if the + currently viewed session is itself still in-flight. + When missing, finishing session A while viewing in-flight session B would + disable B's Send button. + """ + src = (REPO_ROOT / "static/messages.js").read_text() + # The fix wraps setBusy(false) in a guard + assert "INFLIGHT[S.session.session_id]" in src, "messages.js must guard setBusy(false) with INFLIGHT check for current session" + + +def test_cancel_button_not_cleared_across_sessions(cleanup_test_sessions): + """R7c: The Cancel button and activeStreamId must only be cleared when the + done/error event belongs to the currently viewed session. + """ + src = (REPO_ROOT / "static/messages.js").read_text() + # Both clear operations must be inside the activeSid === S.session guard + # We check for the pattern added by the fix + assert "S.session.session_id===activeSid" in src, "messages.js must guard activeStreamId/Cancel clearing with session identity check" + +# ── R8: Session delete does not invalidate index (ghost sessions) ───────────── + +def test_deleted_session_does_not_appear_in_list(cleanup_test_sessions): + """R8: After deleting a session, it must not appear in /api/sessions. + When _index.json was not invalidated on delete, the session reappeared + in the list even after the JSON file was removed. + """ + # Create a session with a title so it shows in the list + d, _ = post("/api/session/new", {}) + sid = d["session"]["session_id"] + post("/api/session/rename", {"session_id": sid, "title": "regression-test-delete-R8"}) + + # Verify it appears + sessions, _ = get("/api/sessions") + ids_before = [s["session_id"] for s in sessions["sessions"]] + assert sid in ids_before, "Session must appear in list before delete" + + # Delete it + result, status = post("/api/session/delete", {"session_id": sid}) + assert status == 200 and result.get("ok") is True + + # Verify it no longer appears -- even after a second fetch (index rebuild) + sessions2, _ = get("/api/sessions") + ids_after = [s["session_id"] for s in sessions2["sessions"]] + assert sid not in ids_after, f"Deleted session {sid} still appears in list -- index not invalidated on delete" + + +def test_server_delete_invalidates_index(cleanup_test_sessions): + """R8b: server.py session/delete handler must unlink _index.json. + Static check that the fix is in place. + """ + src = (REPO_ROOT / "server.py").read_text() + # Find the delete handler and verify it unlinks the index + delete_idx = src.find("if parsed.path == '/api/session/delete':") + assert delete_idx >= 0, "session/delete handler not found" + delete_block = src[delete_idx:delete_idx+600] + assert "SESSION_INDEX_FILE" in delete_block, "server.py session/delete must invalidate SESSION_INDEX_FILE" + + +# ── R9: Token/tool SSE events write to wrong session after switch ───────────── + +def test_token_handler_guards_session_id(cleanup_test_sessions): + """R9a: The SSE token event handler must check activeSid before writing to DOM. + When missing, tokens from session A would render into session B's message area + if the user switched sessions mid-stream. + """ + src = (REPO_ROOT / "static/messages.js").read_text() + # Find the token event handler + token_idx = src.find("es.addEventListener('token'") + assert token_idx >= 0, "token event handler not found" + token_block = src[token_idx:token_idx+300] + assert "activeSid" in token_block, "token handler must check activeSid before writing to DOM" + assert "S.session.session_id!==activeSid" in token_block or "S.session.session_id===activeSid" in token_block, "token handler must compare current session to activeSid" + + +def test_tool_handler_guards_session_id(cleanup_test_sessions): + """R9b: The SSE tool event handler must check activeSid before writing to DOM. + When missing, tool cards from session A would render into session B's message area. + """ + src = (REPO_ROOT / "static/messages.js").read_text() + tool_idx = src.find("es.addEventListener('tool'") + assert tool_idx >= 0, "tool event handler not found" + tool_block = src[tool_idx:tool_idx+400] + assert "activeSid" in tool_block, "tool handler must check activeSid before writing to DOM" + +# ── R10: respondApproval uses wrong session_id after switch (multi-session) ─ + +def test_respond_approval_uses_approval_session_id(cleanup_test_sessions): + """R10: respondApproval must use the session_id of the session that triggered + the approval, not S.session.session_id (which may be a different session + if the user switched while approval was pending). + """ + src = (REPO_ROOT / "static/messages.js").read_text() + # The fix introduces _approvalSessionId to track the correct session + assert "_approvalSessionId" in src, "messages.js must use _approvalSessionId in respondApproval" + # respondApproval must use _approvalSessionId, not S.session.session_id directly + idx = src.find("async function respondApproval(") + assert idx >= 0, "respondApproval not found" + fn_body = src[idx:idx+300] + assert "_approvalSessionId" in fn_body, "respondApproval must read _approvalSessionId, not S.session.session_id" + + +# ── R11: Activity bar shows cross-session tool status ───────────────────── + +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 + fire when the user is viewing the session that triggered the tool. + When missing, session A's tool names would appear in session B's activity bar. + """ + src = (REPO_ROOT / "static/messages.js").read_text() + # Find the tool event handler + tool_idx = src.find("es.addEventListener('tool'") + assert tool_idx >= 0 + tool_block = src[tool_idx:tool_idx+400] + # setStatus must be inside the activeSid guard, not before it + status_pos = tool_block.find("setStatus(") + guard_pos = tool_block.find("S.session.session_id===activeSid") + assert guard_pos >= 0, "tool handler must guard with activeSid check" + # 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 ────────────── + +def test_loadSession_inflight_restores_live_tool_cards(cleanup_test_sessions): + """R12: When switching back to an in-flight session, live tool cards in + #liveToolCards must be restored from S.toolCalls. + When missing, tool cards disappeared on switch-away even though the session + was still processing. + """ + src = (REPO_ROOT / "static/sessions.js").read_text() + # INFLIGHT branch must call appendLiveToolCard + inflight_idx = src.find("if(INFLIGHT[sid]){") + assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession" + inflight_block = src[inflight_idx:inflight_idx+500] + assert "appendLiveToolCard" in inflight_block, "loadSession INFLIGHT branch must restore live tool cards via appendLiveToolCard" + assert "clearLiveToolCards" in inflight_block, "loadSession INFLIGHT branch must clear old live cards before restoring" + +# ── R13: renderMessages() called before S.busy=false in done handler ──────── + +def test_done_handler_sets_busy_false_before_renderMessages(cleanup_test_sessions): + """R13: In the done handler, S.busy must be set to false BEFORE renderMessages() + is called for the active session. The !S.busy guard in renderMessages() controls + whether settled tool cards are rendered. When S.busy=true during renderMessages(), + tool cards are skipped entirely after a response completes. + """ + src = (REPO_ROOT / "static/messages.js").read_text() + done_idx = src.find("es.addEventListener('done'") + assert done_idx >= 0 + done_block = src[done_idx:done_idx+1500] + # S.busy=false must appear before renderMessages() within the done handler + busy_pos = done_block.find("S.busy=false;") + render_pos = done_block.find("renderMessages()") + assert busy_pos >= 0, "done handler must set S.busy=false before renderMessages()" + assert busy_pos < render_pos, f"S.busy=false (pos {busy_pos}) must come before renderMessages() (pos {render_pos})" + + +# ── R14: send() uses stale modelSelect.value instead of session model ──────── + +def test_send_uses_session_model_as_authoritative_source(cleanup_test_sessions): + """R14: send() must use S.session.model as the authoritative model, not just + $('modelSelect').value. When a session was created with a model not in the + current dropdown list, the select value would be stale after switching sessions, + causing the wrong model to be sent. + """ + src = (REPO_ROOT / "static/messages.js").read_text() + # The model field in the chat/start payload must prefer S.session.model + chat_start_idx = src.find("/api/chat/start") + assert chat_start_idx >= 0 + payload_block = src[chat_start_idx:chat_start_idx+300] + assert "S.session.model" in payload_block, "send() must use S.session.model in the chat/start payload" + + +# ── R15: newSession does not clear live tool cards ──────────────────────────── + +def test_newSession_clears_live_tool_cards(cleanup_test_sessions): + """R15: newSession() must call clearLiveToolCards() so live cards from a + previous in-flight session don't persist when starting a fresh conversation. + """ + src = (REPO_ROOT / "static/sessions.js").read_text() + new_sess_idx = src.find("async function newSession(") + assert new_sess_idx >= 0 + # Find end of newSession (next async function) + next_fn = src.find("async function ", new_sess_idx + 10) + new_sess_body = src[new_sess_idx:next_fn] + assert "clearLiveToolCards" in new_sess_body, "newSession() must call clearLiveToolCards() to clear stale live cards" diff --git a/tests/test_sprint1.py b/tests/test_sprint1.py new file mode 100644 index 0000000..8e8893a --- /dev/null +++ b/tests/test_sprint1.py @@ -0,0 +1,437 @@ +""" +Sprint 1 test suite for the Hermes WebUI. + +Tests use the ISOLATED test server running on http://127.0.0.1:8788. +Production server (port 8787) and your real conversations are never touched. +Start the server before running: + /start.sh + # wait 2 seconds + pytest webui-mvp/tests/test_sprint1.py -v + +All tests are HTTP-level: they call real API endpoints and verify responses. +No mocking required for session CRUD, upload parser, or approval API. +""" + +import io +import json +import os +import sys +import time +import uuid +import urllib.request +import urllib.parse +import urllib.error +import tempfile +import pathlib + +# Allow importing server modules directly for unit tests +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.parent)) + +BASE = "http://127.0.0.1:8788" # test server (isolated from production) + + +# ────────────────────────────────────────────── +# HTTP helpers +# ────────────────────────────────────────────── + +def get(path): + url = BASE + path + with urllib.request.urlopen(url, timeout=10) as r: + return json.loads(r.read()) + + +def post(path, body=None): + url = BASE + path + data = json.dumps(body or {}).encode() + req = urllib.request.Request(url, data=data, + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + + +def post_multipart(path, fields, files): + """Post a multipart/form-data request. files: {name: (filename, bytes)}""" + boundary = uuid.uuid4().hex.encode() + body = b"" + for name, value in fields.items(): + body += b"--" + boundary + b"\r\n" + body += f"Content-Disposition: form-data; name=\"{name}\"\r\n\r\n".encode() + body += value.encode() + b"\r\n" + for name, (filename, data) in files.items(): + body += b"--" + boundary + b"\r\n" + body += f"Content-Disposition: form-data; name=\"{name}\"; filename=\"{filename}\"\r\n".encode() + body += b"Content-Type: application/octet-stream\r\n\r\n" + body += data + b"\r\n" + body += b"--" + boundary + b"--\r\n" + req = urllib.request.Request(BASE + path, data=body, + headers={"Content-Type": f"multipart/form-data; boundary={boundary.decode()}"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + + +def make_session_tracked(created_list, ws=None): + """Create a session and register it with the cleanup fixture.""" + body = {} + if ws: body["workspace"] = str(ws) + d, _ = post("/api/session/new", body) + sid = d["session"]["session_id"] + created_list.append(sid) + return sid, pathlib.Path(d["session"]["workspace"]) + + + +# ────────────────────────────────────────────── +# Health check (prerequisite for all tests) +# ────────────────────────────────────────────── + +def test_health(): + """Server must be running and healthy.""" + data = get("/health") + assert data["status"] == "ok", f"health not ok: {data}" + + +# ────────────────────────────────────────────── +# B11: /api/session GET footgun fix +# ────────────────────────────────────────────── + +def test_session_get_no_id_returns_400(): + """B11: GET /api/session with no session_id must return 400, not silently create.""" + try: + data = get("/api/session") + # If we get here, the server returned 200 (old broken behavior) + assert False, f"Expected 400 but got 200: {data}" + except urllib.error.HTTPError as e: + assert e.code == 400, f"Expected 400, got {e.code}" + body = json.loads(e.read()) + assert "error" in body + + +# ────────────────────────────────────────────── +# Session CRUD +# ────────────────────────────────────────────── + +def test_session_create_and_load(): + """Create a session, verify it appears in /api/sessions, load it.""" + data, status = post("/api/session/new", {"model": "openai/gpt-5.4-mini"}) + assert status == 200, f"Expected 200, got {status}: {data}" + assert "session" in data + sid = data["session"]["session_id"] + assert len(sid) == 12 # uuid4().hex[:12] + + # Give it a title so it's visible in the session list (empty Untitled sessions are filtered) + post("/api/session/rename", {"session_id": sid, "title": "test-create-verify"}) + + # Verify it appears in /api/sessions list + sessions = get("/api/sessions") + sids = [s["session_id"] for s in sessions["sessions"]] + assert sid in sids, f"New session {sid} not in sessions list" + + # Load it directly + loaded = get(f"/api/session?session_id={sid}") + assert loaded["session"]["session_id"] == sid + assert loaded["session"]["messages"] == [] + + # Cleanup + post("/api/session/delete", {"session_id": sid}) + + +def test_session_update(): + """Create session, update workspace and model, verify persisted.""" + data, _ = post("/api/session/new", {}) + sid = data["session"]["session_id"] + + updated, status = post("/api/session/update", { + "session_id": sid, + "workspace": "/tmp", + "model": "anthropic/claude-sonnet-4.6" + }) + assert status == 200 + assert updated["session"]["model"] == "anthropic/claude-sonnet-4.6" + + # Reload and verify persistence + reloaded = get(f"/api/session?session_id={sid}") + assert reloaded["session"]["model"] == "anthropic/claude-sonnet-4.6" + + +def test_session_delete(): + """Create session, delete it, verify it no longer loads.""" + data, _ = post("/api/session/new", {}) + sid = data["session"]["session_id"] + + result, status = post("/api/session/delete", {"session_id": sid}) + assert status == 200 + assert result.get("ok") is True + + # Trying to load it should now 404/500 (KeyError -> 500 in current handler) + try: + get(f"/api/session?session_id={sid}") + assert False, "Expected error loading deleted session" + except urllib.error.HTTPError as e: + assert e.code in (404, 500), f"Expected 404 or 500, got {e.code}" + + +def test_session_delete_nonexistent(): + """Deleting a nonexistent session should return ok:True (idempotent).""" + result, status = post("/api/session/delete", {"session_id": "doesnotexist"}) + assert status == 200 + assert result.get("ok") is True + + +def test_sessions_list_sorted(): + """Sessions list should be sorted most-recently-updated first.""" + # Create two sessions with a title so they're visible (empty Untitled sessions are filtered) + a, _ = post("/api/session/new", {}) + time.sleep(0.05) + b, _ = post("/api/session/new", {}) + sid_a = a["session"]["session_id"] + sid_b = b["session"]["session_id"] + post("/api/session/rename", {"session_id": sid_a, "title": "test-sort-a"}) + time.sleep(0.05) + post("/api/session/rename", {"session_id": sid_b, "title": "test-sort-b"}) + + sessions = get("/api/sessions") + sids = [s["session_id"] for s in sessions["sessions"]] + + # b was updated more recently, should appear before a + assert sids.index(sid_b) < sids.index(sid_a), \ + "Sessions not sorted by updated_at desc" + + # Cleanup + post("/api/session/delete", {"session_id": sid_a}) + post("/api/session/delete", {"session_id": sid_b}) + + +# ────────────────────────────────────────────── +# Upload parser unit tests (pure function, no HTTP) +# ────────────────────────────────────────────── + +def test_parse_multipart_text_file(): + """parse_multipart correctly parses a text file field.""" + sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.parent)) + # Import the function directly from the server module + import importlib.util + spec = importlib.util.spec_from_file_location( + "server", + str(pathlib.Path(__file__).parent.parent / "server.py") + ) + # We only need parse_multipart; import it without running the server + # Parse manually by reading the source and exec only the function + src = pathlib.Path(__file__).parent.parent.joinpath("api/upload.py").read_text() + # Extract and exec parse_multipart + import re + # Find the function + m = re.search(r"(def parse_multipart\(.*?)(?=\ndef )", src, re.DOTALL) + assert m, "Could not find parse_multipart in server.py" + ns = {} + exec("import re as _re, email.parser as _ep\n" + m.group(1), ns) + parse_multipart = ns["parse_multipart"] + + # Build a minimal multipart body + boundary = b"testboundary" + body = ( + b"--testboundary\r\n" + b"Content-Disposition: form-data; name=\"session_id\"\r\n\r\n" + b"abc123\r\n" + b"--testboundary\r\n" + b"Content-Disposition: form-data; name=\"file\"; filename=\"hello.txt\"\r\n" + b"Content-Type: text/plain\r\n\r\n" + b"hello world\r\n" + b"--testboundary--\r\n" + ) + fields, files = parse_multipart( + io.BytesIO(body), + "multipart/form-data; boundary=testboundary", + len(body) + ) + assert fields.get("session_id") == "abc123", f"fields: {fields}" + assert "file" in files, f"files: {files}" + filename, content = files["file"] + assert filename == "hello.txt" + assert content == b"hello world" + + +def test_parse_multipart_binary_file(): + """parse_multipart handles binary (PNG header bytes) without corruption.""" + src = pathlib.Path(__file__).parent.parent.joinpath("api/upload.py").read_text() + import re + m = re.search(r"(def parse_multipart\(.*?)(?=\ndef )", src, re.DOTALL) + ns = {} + exec("import re as _re, email.parser as _ep\n" + m.group(1), ns) + parse_multipart = ns["parse_multipart"] + + # Fake PNG: first 8 bytes of PNG magic + png_magic = b"\x89PNG\r\n\x1a\n" + boundary = b"binboundary" + body = ( + b"--binboundary\r\n" + b"Content-Disposition: form-data; name=\"session_id\"\r\n\r\n" + b"sess1\r\n" + b"--binboundary\r\n" + b"Content-Disposition: form-data; name=\"file\"; filename=\"test.png\"\r\n" + b"Content-Type: image/png\r\n\r\n" + png_magic + b"\r\n" + b"--binboundary--\r\n" + ) + fields, files = parse_multipart( + io.BytesIO(body), + "multipart/form-data; boundary=binboundary", + len(body) + ) + assert "file" in files + filename, content = files["file"] + assert filename == "test.png" + assert content == png_magic, f"Binary content corrupted: {content!r}" + + +# ────────────────────────────────────────────── +# File upload via HTTP +# ────────────────────────────────────────────── + +def test_upload_text_file(cleanup_test_sessions): + """Upload a text file to a session workspace, verify it appears in /api/list.""" + sid, ws = make_session_tracked(cleanup_test_sessions) + + result, status = post_multipart("/api/upload", {"session_id": sid}, { + "file": ("test_upload.txt", b"sprint1 test content") + }) + assert status == 200, f"Upload failed {status}: {result}" + assert "filename" in result + assert result["size"] == len(b"sprint1 test content") + + # Verify file appears in listing + listing = get(f"/api/list?session_id={sid}&path=.") + names = [e["name"] for e in listing["entries"]] + assert result["filename"] in names, f"{result['filename']} not in {names}" + # Cleanup the uploaded file + post("/api/file/delete", {"session_id": sid, "path": result["filename"]}) + + +def test_upload_too_large(cleanup_test_sessions): + """Uploading a file over MAX_UPLOAD_BYTES is rejected (413 or connection closed).""" + sid, _ = make_session_tracked(cleanup_test_sessions) + + # 21MB > 20MB limit + big = b"x" * (21 * 1024 * 1024) + try: + result, status = post_multipart("/api/upload", {"session_id": sid}, { + "file": ("big.bin", big) + }) + # If we get a response it should be 413 + assert status == 413, f"Expected 413, got {status}: {result}" + except (urllib.error.URLError, ConnectionResetError, BrokenPipeError): + # Server closed connection after reading Content-Length > limit before body + # This is also valid rejection behavior + pass + + +def test_upload_no_file_field(cleanup_test_sessions): + """Upload with no file field returns 400.""" + sid, _ = make_session_tracked(cleanup_test_sessions) + result, status = post_multipart("/api/upload", {"session_id": sid}, {}) + assert status == 400, f"Expected 400, got {status}: {result}" + + +def test_upload_bad_session(): + """Upload to nonexistent session returns 404.""" + result, status = post_multipart("/api/upload", {"session_id": "nosuchsession"}, { + "file": ("x.txt", b"data") + }) + assert status == 404, f"Expected 404, got {status}: {result}" + + +# ────────────────────────────────────────────── +# Approval API +# ────────────────────────────────────────────── + +def test_approval_pending_none(): + """GET /api/approval/pending for a session with no pending entry returns null.""" + data = get("/api/approval/pending?session_id=no_such_session") + assert data["pending"] is None + + +def test_approval_submit_and_respond(): + """Inject a pending approval via server endpoint, retrieve it, respond with deny.""" + test_sid = f"test-approval-{uuid.uuid4().hex[:6]}" + cmd = "rm -rf /tmp/testdir" + key = "recursive_delete" + + # Inject into server process via test endpoint (shared module state) + inject = get(f"/api/approval/inject_test?session_id={urllib.parse.quote(test_sid)}&pattern_key={key}&command={urllib.parse.quote(cmd)}") + assert inject["ok"] is True + + # Poll should now show the pending entry + data = get(f"/api/approval/pending?session_id={urllib.parse.quote(test_sid)}") + assert data["pending"] is not None, "Pending entry not visible after inject" + assert data["pending"]["command"] == cmd + + # Respond with deny + result, status = post("/api/approval/respond", { + "session_id": test_sid, + "choice": "deny" + }) + assert status == 200 + assert result["ok"] is True + assert result["choice"] == "deny" + + # Pending should be gone + data2 = get(f"/api/approval/pending?session_id={urllib.parse.quote(test_sid)}") + assert data2["pending"] is None, "Pending entry should be cleared after respond" + + +def test_approval_respond_allow_session(): + """Inject pending entry, respond with session choice, verify cleared (approved).""" + test_sid = f"test-approval-sess-{uuid.uuid4().hex[:6]}" + + inject = get(f"/api/approval/inject_test?session_id={urllib.parse.quote(test_sid)}&pattern_key=force_kill&command=pkill+-9+someproc") + assert inject["ok"] is True + + result, status = post("/api/approval/respond", { + "session_id": test_sid, + "choice": "session" + }) + assert status == 200 + assert result["ok"] is True + assert result["choice"] == "session" + + # After session approval, pending should be cleared + data = get(f"/api/approval/pending?session_id={urllib.parse.quote(test_sid)}") + assert data["pending"] is None, "Pending entry should be cleared after session approval" + + +# ────────────────────────────────────────────── +# Stream status endpoint (B4/B5) +# ────────────────────────────────────────────── + +def test_stream_status_unknown_id(): + """GET /api/chat/stream/status for unknown stream_id returns active:false.""" + data = get("/api/chat/stream/status?stream_id=doesnotexist") + assert data["active"] is False + + +# ────────────────────────────────────────────── +# File browser +# ────────────────────────────────────────────── + +def test_list_dir(cleanup_test_sessions): + """List workspace directory for a session.""" + sid, _ = make_session_tracked(cleanup_test_sessions) + listing = get(f"/api/list?session_id={sid}&path=.") + assert "entries" in listing + assert isinstance(listing["entries"], list) + + +def test_list_dir_path_traversal(cleanup_test_sessions): + """Path traversal via ../.. should be blocked (500 or 400).""" + sid, _ = make_session_tracked(cleanup_test_sessions) + try: + listing = get(f"/api/list?session_id={sid}&path=../../etc") + # If server returns entries outside workspace root, that is a bug + # (safe_resolve should raise ValueError) + assert False, f"Expected error for path traversal, got: {listing}" + except urllib.error.HTTPError as e: + assert e.code in (400, 404, 500), f"Expected 400/404/500 for traversal, got {e.code}" diff --git a/tests/test_sprint10.py b/tests/test_sprint10.py new file mode 100644 index 0000000..8abeff3 --- /dev/null +++ b/tests/test_sprint10.py @@ -0,0 +1,139 @@ +""" +Sprint 10 Tests: server.py split, cancel endpoint, cron history, tool card polish. +""" +import json, pathlib, urllib.error, urllib.request, urllib.parse +REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve() + +BASE = "http://127.0.0.1:8788" + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + +def get_text(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return r.read().decode(), r.status + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + +def make_session(created_list): + d, _ = post("/api/session/new", {}) + sid = d["session"]["session_id"] + created_list.append(sid) + return sid + +# ── server.py split: api/ modules served / importable ───────────────────── + +def test_health_still_works(cleanup_test_sessions): + data, status = get("/health") + assert status == 200 + assert data["status"] == "ok" + assert "uptime_seconds" in data + assert "active_streams" in data + +def test_api_modules_exist(cleanup_test_sessions): + """All api/ module files must exist on disk.""" + base = REPO_ROOT / "api" + for mod in ["__init__.py", "config.py", "helpers.py", "models.py", + "workspace.py", "upload.py", "streaming.py"]: + assert (base / mod).exists(), f"Missing api/{mod}" + +def test_server_py_under_750_lines(cleanup_test_sessions): + """server.py should be under 750 lines after the split.""" + lines = len((REPO_ROOT / "server.py").read_text().splitlines()) + assert lines < 750, f"server.py is {lines} lines -- split may not have landed" + +def test_api_config_has_cancel_flags(cleanup_test_sessions): + src = (REPO_ROOT / "api/config.py").read_text() + assert "CANCEL_FLAGS" in src + assert "STREAMS" in src + +def test_session_crud_still_works(cleanup_test_sessions): + """Full session lifecycle works after split.""" + created = [] + sid = make_session(created) + data, status = get(f"/api/session?session_id={urllib.parse.quote(sid)}") + assert status == 200 + assert data["session"]["session_id"] == sid + post("/api/session/delete", {"session_id": sid}) + +def test_static_files_still_served(cleanup_test_sessions): + for f in ["ui.js", "workspace.js", "sessions.js", "messages.js", "panels.js", "boot.js"]: + src, status = get_text(f"/static/{f}") + assert status == 200, f"/static/{f} returned {status}" + assert len(src) > 100 + +# ── Cancel endpoint ──────────────────────────────────────────────────────── + +def test_cancel_requires_stream_id(cleanup_test_sessions): + try: + data, status = get("/api/chat/cancel") + assert status == 400 + except urllib.error.HTTPError as e: + assert e.code == 400 + +def test_cancel_nonexistent_stream(cleanup_test_sessions): + data, status = get("/api/chat/cancel?stream_id=nonexistent_xyz") + assert status == 200 + assert data["ok"] is True + assert data["cancelled"] is False + +def test_cancel_button_in_html(cleanup_test_sessions): + src, _ = get_text("/") + assert "btnCancel" in src + assert "cancelStream" in src + +def test_cancel_function_in_boot_js(cleanup_test_sessions): + src, _ = get_text("/static/boot.js") + assert "async function cancelStream(" in src + assert "/api/chat/cancel" in src + +# ── Cron history ─────────────────────────────────────────────────────────── + +def test_crons_output_limit_param(cleanup_test_sessions): + """Server accepts limit parameter > 1.""" + data, status = get("/api/crons/output?job_id=nonexistent&limit=20") + # 404 or 200 with empty -- both acceptable for nonexistent job + assert status in (200, 404) + +def test_cron_history_button_in_panels_js(cleanup_test_sessions): + src, _ = get_text("/static/panels.js") + assert "loadCronHistory" in src + assert "All runs" in src + +def test_cron_output_snippet_helper(cleanup_test_sessions): + src, _ = get_text("/static/panels.js") + assert "_cronOutputSnippet" in src + +# ── Tool card polish ─────────────────────────────────────────────────────── + +def test_tool_card_running_dot_in_css(cleanup_test_sessions): + src, _ = get_text("/static/style.css") + assert "tool-card-running-dot" in src + +def test_tool_card_show_more_in_ui_js(cleanup_test_sessions): + src, _ = get_text("/static/ui.js") + assert "Show more" in src + assert "tool-card-more" in src + +def test_tool_card_smart_truncation_in_ui_js(cleanup_test_sessions): + src, _ = get_text("/static/ui.js") + assert "displaySnippet" in src + assert "lastBreak" in src + +def test_cancel_sse_event_handler_in_messages_js(cleanup_test_sessions): + src, _ = get_text("/static/messages.js") + assert "addEventListener('cancel'" in src + assert "Task cancelled" in src + +def test_active_stream_id_tracked(cleanup_test_sessions): + src, _ = get_text("/static/messages.js") + assert "S.activeStreamId" in src diff --git a/tests/test_sprint2.py b/tests/test_sprint2.py new file mode 100644 index 0000000..0be15a6 --- /dev/null +++ b/tests/test_sprint2.py @@ -0,0 +1,106 @@ +"""Sprint 2 tests: image preview, file types, markdown. Uses cleanup_test_sessions fixture.""" +import io, json, uuid, urllib.request, urllib.error, pathlib + +BASE = "http://127.0.0.1:8788" # test server (isolated from production) + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + +def get_raw(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return r.read(), r.headers.get('Content-Type', ''), r.status + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + +def make_session_tracked(created_list, ws=None): + """Create a session and register it with the cleanup fixture.""" + import pathlib as _pathlib + body = {} + if ws: body["workspace"] = str(ws) + d, _ = post("/api/session/new", body) + sid = d["session"]["session_id"] + created_list.append(sid) + return sid, _pathlib.Path(d["session"]["workspace"]) + + + +def test_raw_endpoint_serves_png(cleanup_test_sessions): + sid, ws = make_session_tracked(cleanup_test_sessions) + png = (b"\x89PNG\r\n\x1a\n" b"\x00\x00\x00\rIHDR\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x08\x02\x00\x00\x00" + b"\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc" + b"\xf8\x0f\x00\x00\x01\x01\x00\x05\x18" + b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82") + (ws / "test.png").write_bytes(png) + raw, ct, status = get_raw(f"/api/file/raw?session_id={sid}&path=test.png") + assert status == 200 + assert "image/png" in ct + assert raw == png + +def test_raw_endpoint_serves_jpeg(cleanup_test_sessions): + sid, ws = make_session_tracked(cleanup_test_sessions) + jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9" + (ws / "photo.jpg").write_bytes(jpeg) + raw, ct, status = get_raw(f"/api/file/raw?session_id={sid}&path=photo.jpg") + assert status == 200 + assert "image/jpeg" in ct + +def test_raw_endpoint_serves_svg(cleanup_test_sessions): + sid, ws = make_session_tracked(cleanup_test_sessions) + svg = b"" + (ws / "icon.svg").write_bytes(svg) + raw, ct, status = get_raw(f"/api/file/raw?session_id={sid}&path=icon.svg") + assert status == 200 + assert "image/svg" in ct + +def test_raw_endpoint_path_traversal_blocked(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + try: + get_raw(f"/api/file/raw?session_id={sid}&path=../../etc/passwd") + assert False + except urllib.error.HTTPError as e: + assert e.code in (400, 500) + +def test_raw_endpoint_missing_file_returns_404(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + try: + get_raw(f"/api/file/raw?session_id={sid}&path=no_such_file.png") + assert False + except urllib.error.HTTPError as e: + assert e.code in (404, 500) + +def test_md_file_returns_text_via_api_file(cleanup_test_sessions): + sid, ws = make_session_tracked(cleanup_test_sessions) + md = "# Hello\n\nThis is **bold**.\n" + (ws / "README.md").write_text(md) + data, status = get(f"/api/file?session_id={sid}&path=README.md") + assert status == 200 + assert data["content"] == md + +def test_md_file_with_table(cleanup_test_sessions): + sid, ws = make_session_tracked(cleanup_test_sessions) + md = "| Name | Value |\n|------|-------|\n| foo | bar |\n" + (ws / "table.md").write_text(md) + data, status = get(f"/api/file?session_id={sid}&path=table.md") + assert status == 200 + assert "| Name | Value |" in data["content"] + +def test_file_listing_includes_images(cleanup_test_sessions): + sid, ws = make_session_tracked(cleanup_test_sessions) + (ws / "photo.png").write_bytes(b"fake png") + (ws / "notes.md").write_text("# Notes") + (ws / "script.py").write_text("print('hello')") + data, status = get(f"/api/list?session_id={sid}&path=.") + assert status == 200 + names = {e["name"]: e for e in data["entries"]} + assert "photo.png" in names + assert "notes.md" in names + assert "script.py" in names diff --git a/tests/test_sprint3.py b/tests/test_sprint3.py new file mode 100644 index 0000000..255801f --- /dev/null +++ b/tests/test_sprint3.py @@ -0,0 +1,144 @@ +"""Sprint 3 tests: cron API, skills API, memory API, input validation.""" +import json, uuid, urllib.request, urllib.error + +BASE = "http://127.0.0.1:8788" # test server (isolated from production) + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + +def make_session_tracked(created_list, ws=None): + """Create a session and register it with the cleanup fixture.""" + import pathlib as _pathlib + body = {} + if ws: body["workspace"] = str(ws) + d, _ = post("/api/session/new", body) + sid = d["session"]["session_id"] + created_list.append(sid) + return sid, _pathlib.Path(d["session"]["workspace"]) + + +def test_crons_list(): + data, status = get("/api/crons") + assert status == 200 + assert "jobs" in data + +def test_crons_list_has_required_fields(): + data, _ = get("/api/crons") + if not data["jobs"]: return + job = data["jobs"][0] + for field in ("id", "name", "prompt", "enabled", "schedule_display"): + assert field in job + +def test_crons_output_requires_job_id(): + try: + get("/api/crons/output") + assert False + except urllib.error.HTTPError as e: + assert e.code == 400 + +def test_crons_output_real_job(): + data, _ = get("/api/crons") + if not data["jobs"]: return + job_id = data["jobs"][0]["id"] + out, status = get(f"/api/crons/output?job_id={job_id}&limit=3") + assert status == 200 + assert "outputs" in out + +def test_crons_pause_requires_job_id(): + result, status = post("/api/crons/pause", {}) + assert status in (400, 404) + +def test_crons_resume_requires_job_id(): + result, status = post("/api/crons/resume", {}) + assert status in (400, 404) + +def test_crons_run_nonexistent(): + result, status = post("/api/crons/run", {"job_id": "doesnotexist999"}) + assert status == 404 + +def test_skills_list(): + data, status = get("/api/skills") + assert status == 200 + assert len(data["skills"]) > 0 + +def test_skills_list_has_required_fields(): + data, _ = get("/api/skills") + skill = data["skills"][0] + assert "name" in skill and "description" in skill + +def test_skills_content_known(): + data, status = get("/api/skills/content?name=dogfood") + assert status == 200 + assert len(data["content"]) > 0 + +def test_skills_content_requires_name(): + try: + get("/api/skills/content") + assert False + except urllib.error.HTTPError as e: + assert e.code == 400 + +def test_skills_search_returns_subset(): + data, _ = get("/api/skills") + assert len(data["skills"]) > 5 + +def test_memory_returns_both_files(): + data, status = get("/api/memory") + assert status == 200 + assert "memory" in data and "user" in data + +def test_memory_content_is_string(): + data, _ = get("/api/memory") + assert isinstance(data["memory"], str) + assert isinstance(data["user"], str) + +def test_memory_has_mtime(): + data, _ = get("/api/memory") + assert "memory_mtime" in data and "user_mtime" in data + +def test_session_update_requires_session_id(): + result, status = post("/api/session/update", {"model": "openai/gpt-5.4-mini"}) + assert status == 400 + +def test_session_delete_requires_session_id(): + result, status = post("/api/session/delete", {}) + assert status == 400 + +def test_chat_start_requires_session_id(): + result, status = post("/api/chat/start", {"message": "hello"}) + assert status == 400 + +def test_chat_start_requires_message(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + result, status = post("/api/chat/start", {"session_id": sid, "message": ""}) + assert status == 400 + +def test_session_update_unknown_id_returns_404(): + result, status = post("/api/session/update", {"session_id": "nosuchsession", "model": "openai/gpt-5.4-mini"}) + assert status == 404 + +def test_session_search_returns_matches(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + post("/api/session/rename", {"session_id": sid, "title": f"unique-s3-{sid}"}) + data, status = get(f"/api/sessions/search?q=unique-s3-{sid}") + assert status == 200 + sids = [s["session_id"] for s in data["sessions"]] + assert sid in sids + +def test_session_search_empty_query_returns_all(): + data, status = get("/api/sessions/search?q=") + assert status == 200 and "sessions" in data + +def test_session_search_no_results(): + data, status = get("/api/sessions/search?q=zzznomatchzzz9999") + assert status == 200 and data["sessions"] == [] diff --git a/tests/test_sprint4.py b/tests/test_sprint4.py new file mode 100644 index 0000000..95ebd20 --- /dev/null +++ b/tests/test_sprint4.py @@ -0,0 +1,156 @@ +"""Sprint 4 tests: relocation, session rename, search, file ops, validation.""" +import json, pathlib, uuid, urllib.request, urllib.error + +BASE = "http://127.0.0.1:8788" # test server (isolated from production) + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + +def get_raw(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return r.read(), r.headers.get("Content-Type",""), r.status + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + +def make_session_tracked(created_list, ws=None): + """Create a session and register it with the cleanup fixture.""" + import pathlib as _pathlib + body = {} + if ws: body["workspace"] = str(ws) + d, _ = post("/api/session/new", body) + sid = d["session"]["session_id"] + created_list.append(sid) + return sid, _pathlib.Path(d["session"]["workspace"]) + + +def test_server_running_from_new_location(): + data, status = get("/health") + assert status == 200 and data["status"] == "ok" + +def test_static_css_served(): + raw, ct, status = get_raw("/static/style.css") + assert status == 200 and "text/css" in ct and b"--bg" in raw + +def test_static_unknown_file_404(): + try: + get_raw("/static/doesnotexist.xyz") + assert False + except urllib.error.HTTPError as e: + assert e.code == 404 + +def test_session_rename(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + result, status = post("/api/session/rename", {"session_id": sid, "title": "Renamed Session"}) + assert status == 200 and result["session"]["title"] == "Renamed Session" + +def test_session_rename_persists(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + post("/api/session/rename", {"session_id": sid, "title": "Persisted"}) + loaded, _ = get(f"/api/session?session_id={sid}") + assert loaded["session"]["title"] == "Persisted" + +def test_session_rename_truncates(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + result, status = post("/api/session/rename", {"session_id": sid, "title": "A" * 200}) + assert status == 200 and len(result["session"]["title"]) <= 80 + +def test_session_rename_requires_fields(): + result, status = post("/api/session/rename", {"session_id": "x"}) + assert status == 400 + result2, status2 = post("/api/session/rename", {"title": "hi"}) + assert status2 == 400 + +def test_session_rename_unknown_id(): + result, status = post("/api/session/rename", {"session_id": "nosuchid", "title": "hi"}) + assert status == 404 + +def test_session_search_returns_matches(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + uid = uuid.uuid4().hex[:8] + post("/api/session/rename", {"session_id": sid, "title": f"s4-search-{uid}"}) + data, status = get(f"/api/sessions/search?q=s4-search-{uid}") + assert status == 200 + sids = [s["session_id"] for s in data["sessions"]] + assert sid in sids + +def test_session_search_empty_query_returns_all(): + data, status = get("/api/sessions/search?q=") + assert status == 200 and "sessions" in data + +def test_session_search_no_results(): + data, status = get("/api/sessions/search?q=zzznomatchzzz9999") + assert status == 200 and data["sessions"] == [] + +def test_file_create(cleanup_test_sessions): + sid, ws = make_session_tracked(cleanup_test_sessions) + fname = f"test_{uuid.uuid4().hex[:6]}.txt" + result, status = post("/api/file/create", {"session_id": sid, "path": fname, "content": "hello sprint4"}) + assert status == 200 and result["ok"] is True + assert (ws / fname).read_text() == "hello sprint4" + +def test_file_create_requires_fields(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + result, status = post("/api/file/create", {"session_id": sid}) + assert status == 400 + result2, status2 = post("/api/file/create", {"path": "x.txt"}) + assert status2 == 400 + +def test_file_create_duplicate_rejected(cleanup_test_sessions): + sid, ws = make_session_tracked(cleanup_test_sessions) + fname = f"dup_{uuid.uuid4().hex[:6]}.txt" + post("/api/file/create", {"session_id": sid, "path": fname, "content": ""}) + result, status = post("/api/file/create", {"session_id": sid, "path": fname, "content": ""}) + assert status == 400 + +def test_file_delete(cleanup_test_sessions): + sid, ws = make_session_tracked(cleanup_test_sessions) + (ws / "to_delete.txt").write_text("bye") + result, status = post("/api/file/delete", {"session_id": sid, "path": "to_delete.txt"}) + assert status == 200 and not (ws / "to_delete.txt").exists() + +def test_file_delete_missing_returns_404(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + result, status = post("/api/file/delete", {"session_id": sid, "path": "nosuchfile.txt"}) + assert status == 404 + +def test_file_delete_path_traversal_blocked(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + result, status = post("/api/file/delete", {"session_id": sid, "path": "../../etc/passwd"}) + assert status in (400, 500) + +def test_list_requires_session_id(): + try: + get("/api/list?path=.") + assert False + except urllib.error.HTTPError as e: + assert e.code == 400 + +def test_file_requires_session_id(): + try: + get("/api/file?path=readme.txt") + assert False + except urllib.error.HTTPError as e: + assert e.code == 400 + +def test_file_requires_path(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + try: + get(f"/api/file?session_id={sid}") + assert False + except urllib.error.HTTPError as e: + assert e.code == 400 + +def test_new_session_inherits_workspace(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + post("/api/session/update", {"session_id": sid, "workspace": "/tmp", "model": "openai/gpt-5.4-mini"}) + sid2, _ = make_session_tracked(cleanup_test_sessions) + data, _ = get(f"/api/session?session_id={sid2}") + assert data["session"]["workspace"] == "/tmp" diff --git a/tests/test_sprint5.py b/tests/test_sprint5.py new file mode 100644 index 0000000..79063af --- /dev/null +++ b/tests/test_sprint5.py @@ -0,0 +1,140 @@ +"""Sprint 5 tests: workspace CRUD, file save, session index, JS serving.""" +import json, pathlib, uuid, urllib.request, urllib.error + +BASE = "http://127.0.0.1:8788" # test server (isolated from production) + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + +def get_raw(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return r.read(), r.headers.get("Content-Type",""), r.status + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + +def make_session_tracked(created_list, ws=None): + """Create a session and register it with the cleanup fixture.""" + import pathlib as _pathlib + body = {} + if ws: body["workspace"] = str(ws) + d, _ = post("/api/session/new", body) + sid = d["session"]["session_id"] + created_list.append(sid) + return sid, _pathlib.Path(d["session"]["workspace"]) + + +def test_server_running_from_new_location(): + data, status = get("/health") + assert status == 200 and data["status"] == "ok" + +def test_app_js_served(): + """Sprint 9: app.js replaced by modules. Verify ui.js (contains renderMd) is served.""" + raw, ct, status = get_raw("/static/ui.js") + assert status == 200 and "javascript" in ct and b"renderMd" in raw + +def test_workspaces_list(): + data, status = get("/api/workspaces") + assert status == 200 and "workspaces" in data and "last" in data + +def test_workspace_add_valid(): + post("/api/workspaces/remove", {"path": "/tmp"}) + result, status = post("/api/workspaces/add", {"path": "/tmp", "name": "Temp"}) + assert status == 200 and any(w["path"]=="/tmp" for w in result["workspaces"]) + post("/api/workspaces/remove", {"path": "/tmp"}) + +def test_workspace_add_validates_existence(): + result, status = post("/api/workspaces/add", {"path": "/tmp/does_not_exist_xyz_999"}) + assert status == 400 + +def test_workspace_add_validates_is_dir(): + result, status = post("/api/workspaces/add", {"path": "/etc/hostname"}) + assert status == 400 + +def test_workspace_add_no_duplicate(): + post("/api/workspaces/remove", {"path": "/tmp"}) + post("/api/workspaces/add", {"path": "/tmp"}) + result, status = post("/api/workspaces/add", {"path": "/tmp"}) + assert status == 400 and "already" in result.get("error","").lower() + post("/api/workspaces/remove", {"path": "/tmp"}) + +def test_workspace_add_requires_path(): + result, status = post("/api/workspaces/add", {}) + assert status == 400 + +def test_workspace_remove(): + post("/api/workspaces/remove", {"path": "/tmp"}) + post("/api/workspaces/add", {"path": "/tmp", "name": "Temp"}) + result, status = post("/api/workspaces/remove", {"path": "/tmp"}) + assert status == 200 and "/tmp" not in [w["path"] for w in result["workspaces"]] + +def test_workspace_rename(): + post("/api/workspaces/remove", {"path": "/tmp"}) + post("/api/workspaces/add", {"path": "/tmp", "name": "Temp"}) + result, status = post("/api/workspaces/rename", {"path": "/tmp", "name": "My Temp"}) + assert status == 200 + assert {w["path"]: w["name"] for w in result["workspaces"]}.get("/tmp") == "My Temp" + post("/api/workspaces/remove", {"path": "/tmp"}) + +def test_workspace_rename_unknown(): + result, status = post("/api/workspaces/rename", {"path": "/no/such/path", "name": "X"}) + assert status == 404 + +def test_last_workspace_updates_on_session_update(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + post("/api/session/update", {"session_id": sid, "workspace": "/tmp", "model": "openai/gpt-5.4-mini"}) + data, _ = get("/api/workspaces") + assert data["last"] == "/tmp" + +def test_file_save(cleanup_test_sessions): + sid, ws = make_session_tracked(cleanup_test_sessions) + fname = f"save_{uuid.uuid4().hex[:6]}.txt" + (ws / fname).write_text("original content") + result, status = post("/api/file/save", {"session_id": sid, "path": fname, "content": "updated"}) + assert status == 200 and (ws / fname).read_text() == "updated" + +def test_file_save_requires_fields(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + result, status = post("/api/file/save", {"session_id": sid}) + assert status == 400 + +def test_file_save_nonexistent_returns_404(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + result, status = post("/api/file/save", {"session_id": sid, "path": "no_such.txt", "content": ""}) + assert status == 404 + +def test_file_save_path_traversal_blocked(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + result, status = post("/api/file/save", {"session_id": sid, "path": "../../etc/passwd", "content": ""}) + assert status in (400, 500) + +def test_session_index_created_after_save(cleanup_test_sessions): + # Index is created in the TEST state dir, not the production dir + test_state_dir = pathlib.Path.home() / ".hermes" / "webui-mvp-test" + index_path = test_state_dir / "sessions" / "_index.json" + make_session_tracked(cleanup_test_sessions) + # Index may not exist yet if cleanup already wiped it -- just check the endpoint works + data, status = get("/api/sessions") + assert status == 200 + assert isinstance(data["sessions"], list) + +def test_sessions_endpoint_returns_sorted(): + data, status = get("/api/sessions") + assert status == 200 + sessions = data["sessions"] + if len(sessions) >= 2: + assert sessions[0]["updated_at"] >= sessions[1]["updated_at"] + +def test_new_session_inherits_last_workspace(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + post("/api/session/update", {"session_id": sid, "workspace": "/tmp", "model": "openai/gpt-5.4-mini"}) + sid2, _ = make_session_tracked(cleanup_test_sessions) + d, _ = get(f"/api/session?session_id={sid2}") + assert d["session"]["workspace"] == "/tmp" diff --git a/tests/test_sprint6.py b/tests/test_sprint6.py new file mode 100644 index 0000000..4914c07 --- /dev/null +++ b/tests/test_sprint6.py @@ -0,0 +1,151 @@ +"""Sprint 6 tests: Escape from editor, Phase D validation, HTML extraction, cron create, session export.""" +import json, uuid, pathlib, urllib.request, urllib.error +REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve() + +BASE = "http://127.0.0.1:8788" # isolated test server + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + +def get_raw(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return r.read(), r.headers, r.status + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + +def make_session_tracked(created_list, ws=None): + body = {} + if ws: body["workspace"] = str(ws) + d, _ = post("/api/session/new", body) + sid = d["session"]["session_id"] + created_list.append(sid) + return sid, pathlib.Path(d["session"]["workspace"]) + +# ── Phase E: HTML served from static/index.html ── + +def test_index_html_served(): + raw, headers, status = get_raw("/") + assert status == 200 + 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"btnExportJSON" in raw, "Export JSON button not found in HTML" + +def test_index_html_file_exists(): + p = REPO_ROOT / "static/index.html" + assert p.exists(), "static/index.html does not exist" + assert p.stat().st_size > 5000, "index.html seems too small" + +def test_server_py_has_no_html_string(): + txt = (REPO_ROOT / "server.py").read_text() + assert 'HTML = r"""' not in txt, "server.py still contains inline HTML string" + assert "doctype html" not in txt.lower(), "server.py still contains raw HTML" + +# ── Phase D: remaining endpoint validation ── + +def test_approval_respond_requires_session_id(): + result, status = post("/api/approval/respond", {"choice": "deny"}) + assert status == 400 + +def test_approval_respond_rejects_invalid_choice(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + result, status = post("/api/approval/respond", {"session_id": sid, "choice": "INVALID"}) + assert status == 400 + +def test_file_raw_requires_session_id(): + try: + get_raw("/api/file/raw?path=test.png") + assert False, "Expected 400" + except urllib.error.HTTPError as e: + assert e.code == 400 + +def test_file_raw_unknown_session(): + try: + get_raw("/api/file/raw?session_id=nosuchsession&path=test.png") + assert False, "Expected 404" + except urllib.error.HTTPError as e: + assert e.code == 404 + +# ── Cron create ── + +def test_cron_create_requires_prompt(): + result, status = post("/api/crons/create", {"schedule": "0 9 * * *"}) + assert status == 400 + assert "prompt" in result.get("error", "").lower() + +def test_cron_create_requires_schedule(): + result, status = post("/api/crons/create", {"prompt": "Say hello"}) + assert status == 400 + assert "schedule" in result.get("error", "").lower() + +def test_cron_create_invalid_schedule(): + result, status = post("/api/crons/create", { + "prompt": "Say hello", "schedule": "not_a_valid_schedule_xyz" + }) + assert status == 400 + +def test_cron_create_success(): + uid = uuid.uuid4().hex[:6] + result, status = post("/api/crons/create", { + "name": f"test-job-{uid}", + "prompt": "Just say 'hello' and nothing else.", + "schedule": "every 999h", # far future -- won't actually run during test + "deliver": "local", + }) + assert status == 200, f"Expected 200 got {status}: {result}" + assert result["ok"] is True + assert "job" in result + job_id = result["job"]["id"] + # Verify it appears in the cron list + jobs, _ = get("/api/crons") + ids = [j["id"] for j in jobs["jobs"]] + assert job_id in ids, f"Created job {job_id} not in list" + +# ── Session export ── + +def test_session_export_requires_session_id(): + try: + get_raw("/api/session/export") + assert False + except urllib.error.HTTPError as e: + assert e.code == 400 + +def test_session_export_unknown_session(): + try: + get_raw("/api/session/export?session_id=nosuchsession") + assert False + except urllib.error.HTTPError as e: + assert e.code == 404 + +def test_session_export_returns_json(cleanup_test_sessions): + sid, _ = make_session_tracked(cleanup_test_sessions) + raw, headers, status = get_raw(f"/api/session/export?session_id={sid}") + assert status == 200 + assert "application/json" in headers.get("Content-Type", "") + data = json.loads(raw) + assert data["session_id"] == sid + assert "messages" in data + assert "title" in data + +# ── Resizable panels: static files present ── + +def test_static_index_has_resize_handles(): + raw, _, status = get_raw("/") + assert status == 200 + assert b"sidebarResize" in raw + assert b"rightpanelResize" in raw + +def test_app_js_has_resize_logic(): + """Sprint 9: app.js replaced by modules. Resize logic lives in boot.js.""" + raw, _, status = get_raw("/static/boot.js") + assert status == 200 + assert b"_initResizePanels" in raw + assert b"hermes-sidebar-w" in raw + assert b"hermes-panel-w" in raw diff --git a/tests/test_sprint7.py b/tests/test_sprint7.py new file mode 100644 index 0000000..1865653 --- /dev/null +++ b/tests/test_sprint7.py @@ -0,0 +1,130 @@ +""" +Sprint 7 Tests: Cron CRUD, Skill CRUD, Memory Write, Session Content Search, Health +""" +import json, pathlib, urllib.error, urllib.parse, urllib.request + +BASE = "http://127.0.0.1:8788" + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()) + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + +def make_session_tracked(created_list, ws=None): + body = {} + if ws: body["workspace"] = str(ws) + d, _ = post("/api/session/new", body) + sid = d["session"]["session_id"] + created_list.append(sid) + return sid, pathlib.Path(d["session"]["workspace"]) + +# ── Health (Phase G) ────────────────────────────────────────────── + +def test_health_has_active_streams(): + data = get("/health") + assert "active_streams" in data + assert isinstance(data["active_streams"], int) and data["active_streams"] >= 0 + +def test_health_has_uptime_seconds(): + data = get("/health") + assert "uptime_seconds" in data + assert isinstance(data["uptime_seconds"], (int, float)) and data["uptime_seconds"] >= 0 + +# ── Session content search ──────────────────────────────────────── + +def test_session_search_empty_returns_all(cleanup_test_sessions): + data = get("/api/sessions/search?q=") + assert "sessions" in data + +def test_session_search_content_params_accepted(cleanup_test_sessions): + data = get("/api/sessions/search?q=hello&content=1&depth=3") + assert "sessions" in data and "query" in data and data["query"] == "hello" + +def test_session_search_returns_count(cleanup_test_sessions): + data = get("/api/sessions/search?q=nonexistent_xyz_9999&content=1") + assert "count" in data and data["count"] == 0 + +# ── Cron update ─────────────────────────────────────────────────── + +def test_cron_update_requires_job_id(cleanup_test_sessions): + data, status = post("/api/crons/update", {"name": "test"}) + assert status == 400 + +def test_cron_update_unknown_job_404(cleanup_test_sessions): + data, status = post("/api/crons/update", {"job_id": "nonexistent_abc123"}) + assert status == 404 + +# ── Cron delete ─────────────────────────────────────────────────── + +def test_cron_delete_requires_job_id(cleanup_test_sessions): + data, status = post("/api/crons/delete", {}) + assert status == 400 + +def test_cron_delete_unknown_404(cleanup_test_sessions): + data, status = post("/api/crons/delete", {"job_id": "nonexistent_xyz999"}) + assert status == 404 + +# ── Skill save ──────────────────────────────────────────────────── + +def test_skill_save_requires_name(cleanup_test_sessions): + data, status = post("/api/skills/save", {"content": "# test"}) + assert status == 400 + +def test_skill_save_requires_content(cleanup_test_sessions): + data, status = post("/api/skills/save", {"name": "test-no-content"}) + assert status == 400 + +def test_skill_save_invalid_name_rejected(cleanup_test_sessions): + data, status = post("/api/skills/save", {"name": "../../../etc/passwd", "content": "bad"}) + assert status == 400 + +def test_skill_save_delete_roundtrip(cleanup_test_sessions): + skill_name = "test-sprint7-skill" + content = "---\nname: test-sprint7-skill\ndescription: Sprint 7 test.\ntags: [test]\n---\n\n# Test\n\nSprint 7 test skill." + data, status = post("/api/skills/save", {"name": skill_name, "content": content}) + assert status == 200 and data.get("ok") is True + skill_path = pathlib.Path(data["path"]) + assert skill_path.exists() and skill_path.read_text() == content + del_data, del_status = post("/api/skills/delete", {"name": skill_name}) + assert del_status == 200 and del_data.get("ok") is True + assert not skill_path.exists() + +def test_skill_delete_requires_name(cleanup_test_sessions): + data, status = post("/api/skills/delete", {}) + assert status == 400 + +def test_skill_delete_unknown_404(cleanup_test_sessions): + data, status = post("/api/skills/delete", {"name": "nonexistent-skill-xyz-9999"}) + assert status == 404 + +# ── Memory write ────────────────────────────────────────────────── + +def test_memory_write_requires_section(cleanup_test_sessions): + data, status = post("/api/memory/write", {"content": "test"}) + assert status == 400 + +def test_memory_write_requires_content(cleanup_test_sessions): + data, status = post("/api/memory/write", {"section": "memory"}) + assert status == 400 + +def test_memory_write_invalid_section(cleanup_test_sessions): + data, status = post("/api/memory/write", {"section": "invalid", "content": "test"}) + assert status == 400 + +def test_memory_write_read_roundtrip(cleanup_test_sessions): + original = get("/api/memory").get("memory", "") + test_content = "# Sprint 7 Test\nWritten by test_memory_write_read_roundtrip." + data, status = post("/api/memory/write", {"section": "memory", "content": test_content}) + assert status == 200 and data.get("ok") is True + read_back = get("/api/memory").get("memory") + assert read_back == test_content + # Restore + post("/api/memory/write", {"section": "memory", "content": original}) diff --git a/tests/test_sprint8.py b/tests/test_sprint8.py new file mode 100644 index 0000000..2841a3d --- /dev/null +++ b/tests/test_sprint8.py @@ -0,0 +1,125 @@ +""" +Sprint 8 Tests: Edit/regenerate, clear conversation, truncate, reconnect banner fix, syntax highlight. +""" +import json, pathlib, urllib.error, urllib.parse, urllib.request + +BASE = "http://127.0.0.1:8788" + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()) + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + +def make_session_tracked(created_list): + d, _ = post("/api/session/new", {}) + sid = d["session"]["session_id"] + created_list.append(sid) + return sid + +# ── /api/session/clear ───────────────────────────────────────────── + +def test_session_clear_requires_session_id(cleanup_test_sessions): + data, status = post("/api/session/clear", {}) + assert status == 400 + +def test_session_clear_unknown_session_404(cleanup_test_sessions): + data, status = post("/api/session/clear", {"session_id": "nonexistent_xyz"}) + assert status == 404 + +def test_session_clear_wipes_messages(cleanup_test_sessions): + created = [] + sid = make_session_tracked(created) + # Inject a fake message directly into the session via rename (to give it a title first) + post("/api/session/rename", {"session_id": sid, "title": "clear-test"}) + # Manually load and verify session exists + sess = get(f"/api/session?session_id={urllib.parse.quote(sid)}") + assert sess["session"]["session_id"] == sid + # Clear it + data, status = post("/api/session/clear", {"session_id": sid}) + assert status == 200, f"Expected 200, got {status}: {data}" + assert data.get("ok") is True + assert data.get("session") is not None + # Load again and verify messages empty + sess2 = get(f"/api/session?session_id={urllib.parse.quote(sid)}") + assert sess2["session"]["messages"] == [] + assert sess2["session"]["title"] == "Untitled" + # Cleanup + post("/api/session/delete", {"session_id": sid}) + +def test_session_clear_returns_session_compact(cleanup_test_sessions): + created = [] + sid = make_session_tracked(created) + data, status = post("/api/session/clear", {"session_id": sid}) + assert status == 200 + assert "session" in data + assert data["session"]["session_id"] == sid + post("/api/session/delete", {"session_id": sid}) + +# ── /api/session/truncate ────────────────────────────────────────── + +def test_session_truncate_requires_session_id(cleanup_test_sessions): + data, status = post("/api/session/truncate", {"keep_count": 2}) + assert status == 400 + +def test_session_truncate_requires_keep_count(cleanup_test_sessions): + data, status = post("/api/session/truncate", {"session_id": "xyz"}) + assert status == 400 + +def test_session_truncate_unknown_session_404(cleanup_test_sessions): + data, status = post("/api/session/truncate", {"session_id": "nonexistent_xyz", "keep_count": 0}) + assert status == 404 + +def test_session_truncate_returns_messages(cleanup_test_sessions): + created = [] + sid = make_session_tracked(created) + data, status = post("/api/session/truncate", {"session_id": sid, "keep_count": 0}) + assert status == 200 + assert data.get("ok") is True + assert "messages" in data["session"] + assert data["session"]["messages"] == [] + post("/api/session/delete", {"session_id": sid}) + +# ── Static files contain new features ───────────────────────────── + +def test_app_js_contains_edit_message(cleanup_test_sessions): + """Verify editMessage function is present in ui.js (Sprint 9: module split).""" + with urllib.request.urlopen(BASE + "/static/ui.js", timeout=10) as r: + src = r.read().decode() + assert "editMessage" in src + assert "msg-edit-area" in src + +def test_app_js_contains_regenerate(cleanup_test_sessions): + with urllib.request.urlopen(BASE + "/static/ui.js", timeout=10) as r: + src = r.read().decode() + assert "regenerateResponse" in src + +def test_app_js_contains_clear_conversation(cleanup_test_sessions): + with urllib.request.urlopen(BASE + "/static/panels.js", timeout=10) as r: + src = r.read().decode() + assert "clearConversation" in src + assert "api/session/clear" in src + +def test_app_js_contains_highlight_code(cleanup_test_sessions): + with urllib.request.urlopen(BASE + "/static/ui.js", timeout=10) as r: + src = r.read().decode() + assert "highlightCode" in src + assert "Prism" in src + +def test_index_html_contains_prism(cleanup_test_sessions): + with urllib.request.urlopen(BASE + "/", timeout=10) as r: + src = r.read().decode() + assert "prismjs" in src.lower() + +def test_index_html_contains_clear_button(cleanup_test_sessions): + with urllib.request.urlopen(BASE + "/", timeout=10) as r: + src = r.read().decode() + assert "btnClearConv" in src + assert "clearConversation" in src diff --git a/tests/test_sprint9.py b/tests/test_sprint9.py new file mode 100644 index 0000000..c5482bc --- /dev/null +++ b/tests/test_sprint9.py @@ -0,0 +1,115 @@ +""" +Sprint 9 Tests: app.js module split verification, tool cards, todo panel. +Run: python -m pytest tests/test_sprint9.py -v +""" +import json, pathlib, urllib.error, urllib.request + +BASE = "http://127.0.0.1:8788" + +def get_text(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return r.read().decode() + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()) + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + +# ── Module split: all 6 files served ────────────────────────────────────── + +def test_ui_js_served(cleanup_test_sessions): + src = get_text("/static/ui.js") + assert len(src) > 1000 + assert "function setBusy" in src + assert "function syncTopbar" in src + assert "const S=" in src or "const S =" in src + +def test_workspace_js_served(cleanup_test_sessions): + src = get_text("/static/workspace.js") + assert "async function api(" in src + assert "async function loadDir(" in src + assert "async function openFile(" in src # renderFileTree is in ui.js + +def test_sessions_js_served(cleanup_test_sessions): + src = get_text("/static/sessions.js") + assert "async function newSession(" in src + assert "async function loadSession(" in src + assert "async function renderSessionList(" in src + +def test_messages_js_served(cleanup_test_sessions): + src = get_text("/static/messages.js") + assert "async function send(" in src + assert "function transcript(" in src + +def test_panels_js_served(cleanup_test_sessions): + src = get_text("/static/panels.js") + assert "async function switchPanel(" in src + assert "async function loadCrons(" in src + assert "async function loadSkills(" in src + assert "async function loadMemory(" in src + +def test_boot_js_served(cleanup_test_sessions): + src = get_text("/static/boot.js") + assert "btnSend" in src + assert "btnNewChat" in src + # boot IIFE + assert "(async()=>{" in src or "(async () => {" in src + +def test_app_js_no_longer_referenced_in_html(cleanup_test_sessions): + """index.html must not reference the old monolithic app.js.""" + html = get_text("/") + assert 'src="/static/app.js"' not in html + # All 6 modules must be present + for module in ["ui.js", "workspace.js", "sessions.js", "messages.js", "panels.js", "boot.js"]: + assert f'src="/static/{module}"' in html, f"Missing {module} in index.html" + +def test_module_load_order_correct(cleanup_test_sessions): + """ui.js must appear before sessions.js which must appear before boot.js.""" + html = get_text("/") + ui_pos = html.find('src="/static/ui.js"') + ws_pos = html.find('src="/static/workspace.js"') + sess_pos = html.find('src="/static/sessions.js"') + msg_pos = html.find('src="/static/messages.js"') + panels_pos = html.find('src="/static/panels.js"') + boot_pos = html.find('src="/static/boot.js"') + assert ui_pos < ws_pos < sess_pos < msg_pos < panels_pos < boot_pos + +def test_no_duplicate_function_definitions(cleanup_test_sessions): + """No function name should appear in more than one module.""" + import re + modules = ["ui.js", "workspace.js", "sessions.js", "messages.js", "panels.js", "boot.js"] + seen = {} + for m in modules: + src = get_text(f"/static/{m}") + fns = re.findall(r'(?:async )?function ([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(', src) + for fn in fns: + if fn in seen: + assert False, f"Duplicate function {fn} in both {seen[fn]} and {m}" + seen[fn] = m + assert len(seen) > 50, f"Expected 50+ functions, got {len(seen)}" + +def test_all_functions_present_across_modules(cleanup_test_sessions): + """Key functions must be present somewhere in the split modules.""" + import re + modules = ["ui.js", "workspace.js", "sessions.js", "messages.js", "panels.js", "boot.js"] + all_src = "" + for m in modules: + all_src += get_text(f"/static/{m}") + required = [ + "setBusy", "syncTopbar", "renderMessages", "send", "loadSession", + "newSession", "renderSessionList", "loadDir", "switchPanel", + "loadCrons", "loadSkills", "loadMemory", "editMessage", + "regenerateResponse", "clearConversation", "highlightCode", + "toggleSkillForm", "submitSkillSave", "toggleMemoryEdit", + ] + for fn in required: + assert fn in all_src, f"Function {fn} missing from all modules"