commit a4e2174c29b34a95f7123b732634afa696e60564 Author: Nathan Esquenazi Date: Mon Mar 30 20:40:19 2026 -0700 Hermes WebUI v0.1.0 — initial public release 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"