2026-03-31 03:40:19 +00:00
|
|
|
async function api(path,opts={}){
|
2026-03-31 14:35:45 +00:00
|
|
|
const url=new URL(path,location.origin);
|
|
|
|
|
const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts});
|
2026-04-03 19:29:24 +00:00
|
|
|
if(!res.ok){
|
|
|
|
|
const text=await res.text();
|
|
|
|
|
// Parse JSON error body and surface the human-readable message,
|
|
|
|
|
// rather than showing raw JSON like {"error":"Profile 'x' does not exist."}
|
|
|
|
|
try{const j=JSON.parse(text);throw new Error(j.error||j.message||text);}
|
|
|
|
|
catch(e){if(e instanceof SyntaxError)throw new Error(text);throw e;}
|
|
|
|
|
}
|
2026-03-31 03:40:19 +00:00
|
|
|
const ct=res.headers.get('content-type')||'';
|
|
|
|
|
return ct.includes('application/json')?res.json():res.text();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 01:59:49 +00:00
|
|
|
// Persist/restore expanded directory state per workspace in localStorage
|
|
|
|
|
function _wsExpandKey(){
|
|
|
|
|
const ws=S.session&&S.session.workspace;
|
|
|
|
|
return ws?'hermes-webui-expanded:'+ws:null;
|
|
|
|
|
}
|
|
|
|
|
function _saveExpandedDirs(){
|
|
|
|
|
const key=_wsExpandKey();if(!key)return;
|
|
|
|
|
try{localStorage.setItem(key,JSON.stringify([...(S._expandedDirs||new Set())]));}catch(e){}
|
|
|
|
|
}
|
|
|
|
|
function _restoreExpandedDirs(){
|
|
|
|
|
const key=_wsExpandKey();
|
|
|
|
|
if(!key){S._expandedDirs=new Set();return;}
|
|
|
|
|
try{
|
|
|
|
|
const raw=localStorage.getItem(key);
|
|
|
|
|
S._expandedDirs=raw?new Set(JSON.parse(raw)):new Set();
|
|
|
|
|
}catch(e){S._expandedDirs=new Set();}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:40:19 +00:00
|
|
|
async function loadDir(path){
|
|
|
|
|
if(!S.session)return;
|
|
|
|
|
try{
|
2026-04-04 01:59:49 +00:00
|
|
|
if(!path||path==='.'){
|
|
|
|
|
S._dirCache={};
|
|
|
|
|
_restoreExpandedDirs(); // restore per-workspace expanded state on root load
|
|
|
|
|
}
|
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
|
|
|
S.currentDir=path||'.';
|
2026-03-31 03:40:19 +00:00
|
|
|
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
|
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
|
|
|
S.entries=data.entries||[];renderBreadcrumb();renderFileTree();
|
2026-04-04 01:59:49 +00:00
|
|
|
// Pre-fetch contents of restored expanded dirs so they render without a second click
|
|
|
|
|
if(!path||path==='.'){
|
|
|
|
|
for(const dirPath of (S._expandedDirs||[])){
|
|
|
|
|
if(!S._dirCache[dirPath]){
|
|
|
|
|
try{
|
|
|
|
|
const dc=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dirPath)}`);
|
|
|
|
|
S._dirCache[dirPath]=dc.entries||[];
|
|
|
|
|
}catch(e2){S._dirCache[dirPath]=[];}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if(S._expandedDirs&&S._expandedDirs.size>0)renderFileTree();
|
|
|
|
|
}
|
2026-04-03 12:02:30 +00:00
|
|
|
if(typeof clearPreview==='function'){
|
|
|
|
|
if(typeof _previewDirty!=='undefined'&&_previewDirty){
|
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255)
When base_url is configured in config.yaml, resolve_model_provider() now
trusts the configured provider/base_url entirely and skips the slash-based
OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom
being silently routed to OpenRouter, resulting in 401 errors.
Fixes #230
* test: mobile layout regression suite — 14 tests for every QA run (#254)
Adds tests/test_mobile_layout.py with 14 static regression tests that run
on every QA pass to catch mobile layout breakage before it reaches prod.
Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile
overlay, bottom nav, files button, profile dropdown z-index, chip overflow,
workspace close, 100dvh, 44px touch targets, 16px font-size on textarea.
* feat: /skills slash command lists and filters available Hermes skills (#257)
Adds /skills [query] command to commands.js. Fetches from /api/skills,
groups by category (alphabetically sorted), displays as a formatted
assistant message. Optional query filters by name, description, or category.
i18n keys added for en, de, zh, zh-Hant. 1 regression test added.
Fixes #248
* feat: shared app dialogs replace native confirm()/prompt() calls (#251)
Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed
by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt
call sites across panels.js, sessions.js, ui.js, workspace.js.
Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore,
ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant.
5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of
native dialog calls.
Extracted from PR #242.
* fix: Android Chrome mobile — workspace panel close + profile dropdown (#256)
Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when
toggling the right workspace panel. New closeMobileFiles() helper closes
the panel with correct overlay state tracking. Overlay onclick calls both
closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x)
added to workspace panel header.
Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at
max-width:900px, escaping the overflow-x:auto stacking context that was
clipping it on Android Chrome.
Fix applied during review: closeMobileSidebar() now checks if the right
panel is still open before hiding the overlay, preventing the overlay from
disappearing when only the sidebar is closed.
Fixes #247 Fixes #246
* feat: session ⋯ action dropdown replaces per-row buttons (#252)
Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash)
with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full
keyboard (Escape), click-outside, scroll, and resize-reposition handling.
Position:fixed prevents sidebar clipping.
5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete
(danger style). Each with icon and descriptive subtitle.
Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons
asserts the new trigger and menu functions exist, old per-row classes are gone.
Extracted from PR #242.
* docs: v0.47.0 release notes, bump version, update test counts (645)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
|
|
|
showConfirmDialog({title:t('unsaved_confirm'),message:'',confirmLabel:'Discard',danger:true,focusCancel:true}).then(ok=>{if(ok)clearPreview();});
|
2026-04-03 12:02:30 +00:00
|
|
|
}else{
|
|
|
|
|
clearPreview();
|
|
|
|
|
}
|
|
|
|
|
}
|
feat: workspace git detection with branch/status badge
When the workspace root is a git repo, a badge in the panel header
shows the current branch name, dirty file count, and ahead/behind
status. Updates on every root directory load.
Backend:
- git_info_for_workspace() in api/workspace.py runs lightweight git
commands (rev-parse, status --porcelain, rev-list) with 3s timeout
- New GET /api/git-info endpoint returns branch, dirty count, modified,
untracked, ahead, behind
Frontend:
- _refreshGitBadge() in workspace.js fetches git info on root load
- Git badge element in panel header shows branch + status
- Badge turns gold when workspace has uncommitted changes
Inspired by PR #75 (@MartinNielsenDev).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:08:25 +00:00
|
|
|
// Fetch git info for workspace root (non-blocking)
|
|
|
|
|
if(!path||path==='.') _refreshGitBadge();
|
2026-03-31 03:40:19 +00:00
|
|
|
}catch(e){console.warn('loadDir',e);}
|
|
|
|
|
}
|
|
|
|
|
|
feat: workspace git detection with branch/status badge
When the workspace root is a git repo, a badge in the panel header
shows the current branch name, dirty file count, and ahead/behind
status. Updates on every root directory load.
Backend:
- git_info_for_workspace() in api/workspace.py runs lightweight git
commands (rev-parse, status --porcelain, rev-list) with 3s timeout
- New GET /api/git-info endpoint returns branch, dirty count, modified,
untracked, ahead, behind
Frontend:
- _refreshGitBadge() in workspace.js fetches git info on root load
- Git badge element in panel header shows branch + status
- Badge turns gold when workspace has uncommitted changes
Inspired by PR #75 (@MartinNielsenDev).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:08:25 +00:00
|
|
|
async function _refreshGitBadge(){
|
|
|
|
|
const badge=$('gitBadge');
|
|
|
|
|
if(!badge||!S.session)return;
|
|
|
|
|
try{
|
|
|
|
|
const data=await api(`/api/git-info?session_id=${encodeURIComponent(S.session.session_id)}`);
|
|
|
|
|
if(data.git&&data.git.is_git){
|
|
|
|
|
const g=data.git;
|
|
|
|
|
let text=g.branch||'git';
|
|
|
|
|
if(g.dirty>0) text+=` \u00b7 ${g.dirty}\u2206`; // middot + delta
|
|
|
|
|
if(g.behind>0) text+=` \u2193${g.behind}`;
|
|
|
|
|
if(g.ahead>0) text+=` \u2191${g.ahead}`;
|
|
|
|
|
badge.textContent=text;
|
|
|
|
|
badge.className='git-badge'+(g.dirty>0?' dirty':'');
|
|
|
|
|
badge.style.display='';
|
|
|
|
|
} else {
|
|
|
|
|
badge.style.display='none';
|
|
|
|
|
badge.textContent='';
|
|
|
|
|
}
|
|
|
|
|
}catch(e){badge.style.display='none';}
|
|
|
|
|
}
|
|
|
|
|
|
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
|
|
|
function navigateUp(){
|
|
|
|
|
if(!S.session||S.currentDir==='.')return;
|
|
|
|
|
const parts=S.currentDir.split('/');
|
|
|
|
|
parts.pop();
|
|
|
|
|
loadDir(parts.length?parts.join('/'):'.');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:40:19 +00:00
|
|
|
// 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';
|
2026-04-08 14:55:03 +00:00
|
|
|
btn.innerHTML = editing ? `💾 ${t('save')}` : `✎ ${t('edit')}`;
|
|
|
|
|
btn.title = editing ? t('save_title') : t('edit_title');
|
2026-03-31 03:40:19 +00:00
|
|
|
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='';
|
2026-04-08 14:55:03 +00:00
|
|
|
showToast(t('saved'));
|
|
|
|
|
}catch(e){setStatus(t('save_failed')+e.message);}
|
2026-03-31 03:40:19 +00:00
|
|
|
}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 <img>
|
|
|
|
|
showPreview('image');
|
|
|
|
|
const url=`/api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`;
|
|
|
|
|
$('previewImg').alt=path;
|
|
|
|
|
$('previewImg').src=url;
|
2026-04-08 14:55:03 +00:00
|
|
|
$('previewImg').onerror=()=>setStatus(t('image_load_failed'));
|
2026-03-31 03:40:19 +00:00
|
|
|
} 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);
|
2026-04-08 14:55:03 +00:00
|
|
|
}catch(e){setStatus(t('file_open_failed'));}
|
2026-03-31 03:40:19 +00:00
|
|
|
} 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);
|
2026-04-08 14:55:03 +00:00
|
|
|
showToast(t('downloading',filename),2000);
|
2026-03-31 03:40:19 +00:00
|
|
|
}
|
|
|
|
|
|