Merge pull request #33 from nesquena/sprint-18-thinking-tree-preview

Sprint 18: File preview auto-close, thinking display, workspace tree view
This commit is contained in:
Nathan Esquenazi
2026-04-03 05:02:47 -07:00
committed by GitHub
7 changed files with 152 additions and 21 deletions

View File

@@ -5,6 +5,41 @@
--- ---
## [v0.20] Sprint 18 -- File Preview Auto-Close + Thinking Display + Workspace Tree
*April 3, 2026 | 318 tests*
### Features
- **File preview auto-close on directory navigation.** When viewing a file in
the right panel and navigating directories (breadcrumbs, up button, folder
clicks), the preview now automatically closes instead of showing stale
content. `clearPreview()` extracted as named function and called from
`loadDir()`. Unsaved preview edits prompt for confirmation before discarding.
- **Thinking/reasoning display.** Assistant messages with structured content
arrays containing `type:'thinking'` or `type:'reasoning'` blocks (Claude
extended thinking, o3 reasoning) now render as collapsible gold-themed cards
above the response text. Collapsed by default. Click the header to expand and
see the model's reasoning process. Uses `esc()` on all content for XSS safety.
- **Workspace tree view (Issue #22).** Directories expand/collapse in-place
with toggle arrows. Single-click toggles a directory open/closed. Double-click
navigates into it (breadcrumb view). Subdirectory contents fetched lazily from
the API and cached in `S._dirCache`. Nesting depth shown via indentation.
Empty directories show "(empty)" placeholder. Breadcrumb navigation still
works alongside the tree view.
### Bug Fixes
- **Stale tree cache on session switch.** `S._dirCache` and `S._expandedDirs`
are now cleared when navigating to the root directory, preventing session B
from showing session A's cached file listings.
- **clearPreview() discards unsaved edits.** Navigation now checks
`_previewDirty` and prompts before discarding unsaved preview changes.
### Architecture
- `clearPreview()` extracted from inline handler to named function in `boot.js`.
- Thinking card styles added to `style.css` (gold-themed, collapsible).
- Tree toggle and empty-directory styles added to `style.css`.
---
## [v0.19] Sprint 17 -- Workspace Polish + Slash Commands + Settings ## [v0.19] Sprint 17 -- Workspace Polish + Slash Commands + Settings
*April 3, 2026 | 318 tests* *April 3, 2026 | 318 tests*
@@ -614,4 +649,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
--- ---
*Last updated: v0.18.1, April 2, 2026 | Tests: 289* *Last updated: v0.20, April 3, 2026 | Tests: 318*

View File

@@ -1,6 +1,6 @@
# Hermes Web UI -- Forward Sprint Plan # Hermes Web UI -- Forward Sprint Plan
> Current state: v0.19 | 318 tests | Daily driver ready > Current state: v0.20 | 318 tests | Daily driver ready
> This document plans the path from here to two targets: > This document plans the path from here to two targets:
> >
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the > Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the

View File

@@ -43,14 +43,16 @@ $('importFileInput').onchange=async(e)=>{
} }
}; };
// btnRefreshFiles is now panel-icon-btn in header (see HTML) // btnRefreshFiles is now panel-icon-btn in header (see HTML)
$('btnClearPreview').onclick=()=>{ function clearPreview(){
$('previewArea').classList.remove('visible'); const pa=$('previewArea');if(pa)pa.classList.remove('visible');
$('previewImg').src=''; const pi=$('previewImg');if(pi)pi.src='';
$('previewMd').innerHTML=''; const pm=$('previewMd');if(pm)pm.innerHTML='';
$('previewCode').textContent=''; const pc=$('previewCode');if(pc)pc.textContent='';
$('previewPathText').textContent=''; const pp=$('previewPathText');if(pp)pp.textContent='';
$('fileTree').style.display=''; const ft=$('fileTree');if(ft)ft.style.display='';
}; _previewCurrentPath='';_previewCurrentMode='';_previewDirty=false;
}
$('btnClearPreview').onclick=clearPreview;
// workspacePath click handler removed -- use topbar workspace chip dropdown instead // workspacePath click handler removed -- use topbar workspace chip dropdown instead
$('modelSelect').onchange=async()=>{ $('modelSelect').onchange=async()=>{
if(!S.session)return; if(!S.session)return;

View File

@@ -13,7 +13,7 @@
<body> <body>
<div class="layout"> <div class="layout">
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.17.1</div></div></div> <div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.20</div></div></div>
<div class="sidebar-nav"> <div class="sidebar-nav">
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">&#128172;</button> <button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">&#128172;</button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">&#128197;</button> <button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">&#128197;</button>

View File

@@ -216,6 +216,9 @@
.file-item{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:7px;cursor:pointer;font-size:12px;color:var(--muted);transition:all .12s;min-width:0;} .file-item{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius: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:hover{background:rgba(255,255,255,.07);color:var(--text);}
.file-item.active{background:rgba(124,185,255,.12);color:var(--blue);} .file-item.active{background:rgba(124,185,255,.12);color:var(--blue);}
.file-tree-toggle{font-size:10px;color:var(--muted);flex-shrink:0;width:10px;text-align:center;line-height:1;}
.file-item.file-empty{color:var(--muted);opacity:.5;font-style:italic;cursor:default;font-size:11px;}
.file-item.file-empty:hover{background:none;color:var(--muted);}
.preview-area{flex:1;overflow:auto;padding:14px;flex-direction:column;gap:8px;display:none;opacity:0;transition:opacity .15s;} .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-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-path{font-size:11px;color:var(--muted);padding-bottom:8px;border-bottom:1px solid var(--border);flex-shrink:0;}
@@ -572,4 +575,16 @@ body.resizing{user-select:none;cursor:col-resize;}
.tool-cards-toggle button{background:none;border:none;color:var(--blue);font-size:10px;cursor:pointer;opacity:.6;padding:0;} .tool-cards-toggle button{background:none;border:none;color:var(--blue);font-size:10px;cursor:pointer;opacity:.6;padding:0;}
.tool-cards-toggle button:hover{opacity:1;text-decoration:underline;} .tool-cards-toggle button:hover{opacity:1;text-decoration:underline;}
/* ── Thinking/reasoning card ── */
.thinking-card{background:rgba(201,168,76,.06);border:1px solid rgba(201,168,76,.2);border-radius:10px;margin:4px 0 2px 40px;overflow:hidden;transition:border-color .15s;}
.thinking-card:hover{border-color:rgba(201,168,76,.35);}
.thinking-card-header{display:flex;align-items:center;gap:6px;padding:6px 12px;cursor:pointer;font-size:12px;color:var(--gold);user-select:none;}
.thinking-card-icon{font-size:14px;}
.thinking-card-label{font-weight:600;letter-spacing:.02em;}
.thinking-card-toggle{margin-left:auto;font-size:10px;transition:transform .15s;}
.thinking-card.open .thinking-card-toggle{transform:rotate(90deg);}
.thinking-card-body{display:none;padding:0 12px 10px;max-height:300px;overflow-y:auto;}
.thinking-card.open .thinking-card-body{display:block;}
.thinking-card-body pre{font-family:'SF Mono',ui-monospace,monospace;font-size:11px;line-height:1.5;color:var(--muted);white-space:pre-wrap;word-break:break-word;margin:0;}
.bg-error-banner{background:rgba(229,62,62,.15);border:1px solid rgba(229,62,62,.3);color:#fca5a5;padding:8px 16px;font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-radius:0;} .bg-error-banner{background:rgba(229,62,62,.15);border:1px solid rgba(229,62,62,.3);color:#fca5a5;padding:8px 16px;font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-radius:0;}

View File

@@ -365,9 +365,20 @@ function renderMessages(){
for(let vi=0;vi<visWithIdx.length;vi++){ for(let vi=0;vi<visWithIdx.length;vi++){
const {m,rawIdx}=visWithIdx[vi]; const {m,rawIdx}=visWithIdx[vi];
let content=m.content||''; let content=m.content||'';
if(Array.isArray(content))content=content.filter(p=>p&&p.type==='text').map(p=>p.text||p.content||'').join('\n'); // Extract thinking/reasoning blocks from structured content (Claude extended thinking, o3)
let thinkingText='';
if(Array.isArray(content)){
thinkingText=content.filter(p=>p&&(p.type==='thinking'||p.type==='reasoning')).map(p=>p.thinking||p.reasoning||p.text||'').join('\n');
content=content.filter(p=>p&&p.type==='text').map(p=>p.text||p.content||'').join('\n');
}
const isUser=m.role==='user'; const isUser=m.role==='user';
const isLastAssistant=!isUser&&vi===visWithIdx.length-1; const isLastAssistant=!isUser&&vi===visWithIdx.length-1;
// Render thinking card before the assistant message (collapsed by default)
if(thinkingText&&!isUser){
const thinkRow=document.createElement('div');thinkRow.className='msg-row thinking-card-row';
thinkRow.innerHTML=`<div class="thinking-card"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">&#128161;</span><span class="thinking-card-label">Thinking</span><span class="thinking-card-toggle">&#9656;</span></div><div class="thinking-card-body"><pre>${esc(thinkingText)}</pre></div></div>`;
inner.appendChild(thinkRow);
}
const row=document.createElement('div');row.className='msg-row'; const row=document.createElement('div');row.className='msg-row';
row.dataset.msgIdx=rawIdx; row.dataset.msgIdx=rawIdx;
let filesHtml=''; let filesHtml='';
@@ -744,27 +755,49 @@ function renderBreadcrumb(){
} }
} }
// Track expanded directories for tree view
if(!S._expandedDirs) S._expandedDirs=new Set();
// Cache of fetched directory contents: path -> entries[]
if(!S._dirCache) S._dirCache={};
function renderFileTree(){ function renderFileTree(){
const box=$('fileTree');box.innerHTML=''; const box=$('fileTree');box.innerHTML='';
for(const item of S.entries){ // Cache current dir entries
S._dirCache[S.currentDir||'.']=S.entries;
_renderTreeItems(box, S.entries, 0);
}
function _renderTreeItems(container, entries, depth){
for(const item of entries){
const el=document.createElement('div');el.className='file-item'; const el=document.createElement('div');el.className='file-item';
el.style.paddingLeft=(8+depth*16)+'px';
if(item.type==='dir'){
// Toggle arrow for directories
const arrow=document.createElement('span');
arrow.className='file-tree-toggle';
const isExpanded=S._expandedDirs.has(item.path);
arrow.textContent=isExpanded?'\u25BE':'\u25B8';
el.appendChild(arrow);
}
// Icon // Icon
const iconEl=document.createElement('span'); const iconEl=document.createElement('span');
iconEl.className='file-icon';iconEl.textContent=fileIcon(item.name,item.type); iconEl.className='file-icon';iconEl.textContent=fileIcon(item.name,item.type);
el.appendChild(iconEl); el.appendChild(iconEl);
// Name -- takes all remaining space, truncates with ellipsis // Name
const nameEl=document.createElement('span'); const nameEl=document.createElement('span');
nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title='Double-click to rename'; nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title='Double-click to rename';
// Inline rename on double-click
nameEl.ondblclick=(e)=>{ nameEl.ondblclick=(e)=>{
e.stopPropagation(); e.stopPropagation();
// For directories, double-click navigates (breadcrumb view)
if(item.type==='dir'){loadDir(item.path);return;}
const inp=document.createElement('input'); const inp=document.createElement('input');
inp.className='file-rename-input';inp.value=item.name; inp.className='file-rename-input';inp.value=item.name;
inp.onclick=(e2)=>e2.stopPropagation(); inp.onclick=(e2)=>e2.stopPropagation();
const finish=async(save)=>{ const finish=async(save)=>{
inp.onblur=null; // prevent double-call: Enter triggers blur after replaceWith inp.onblur=null;
if(save){ if(save){
const newName=inp.value.trim(); const newName=inp.value.trim();
if(newName&&newName!==item.name){ if(newName&&newName!==item.name){
@@ -773,6 +806,8 @@ function renderFileTree(){
session_id:S.session.session_id,path:item.path,new_name:newName session_id:S.session.session_id,path:item.path,new_name:newName
})}); })});
showToast(`Renamed to ${newName}`); showToast(`Renamed to ${newName}`);
// Invalidate cache and re-render
delete S._dirCache[S.currentDir];
await loadDir(S.currentDir); await loadDir(S.currentDir);
}catch(err){showToast('Rename failed: '+err.message);} }catch(err){showToast('Rename failed: '+err.message);}
} }
@@ -789,7 +824,7 @@ function renderFileTree(){
}; };
el.appendChild(nameEl); el.appendChild(nameEl);
// Size -- only for files, right-aligned, shrinks but never wraps // Size -- only for files
if(item.type==='file'&&item.size){ if(item.type==='file'&&item.size){
const sizeEl=document.createElement('span'); const sizeEl=document.createElement('span');
sizeEl.className='file-size'; sizeEl.className='file-size';
@@ -797,16 +832,52 @@ function renderFileTree(){
el.appendChild(sizeEl); el.appendChild(sizeEl);
} }
// Delete button -- for files, shown on hover // Delete button -- for files
if(item.type==='file'){ if(item.type==='file'){
const del=document.createElement('button'); const del=document.createElement('button');
del.className='file-del-btn';del.title='Delete';del.textContent='×'; del.className='file-del-btn';del.title='Delete';del.textContent='\u00d7';
del.onclick=async(e)=>{e.stopPropagation();await deleteWorkspaceFile(item.path,item.name);}; del.onclick=async(e)=>{e.stopPropagation();await deleteWorkspaceFile(item.path,item.name);};
el.appendChild(del); el.appendChild(del);
} }
el.onclick=async()=>item.type==='dir'?loadDir(item.path):openFile(item.path); if(item.type==='dir'){
box.appendChild(el); // Single-click toggles expand/collapse
el.onclick=async(e)=>{
e.stopPropagation();
if(S._expandedDirs.has(item.path)){
S._expandedDirs.delete(item.path);
renderFileTree();
}else{
S._expandedDirs.add(item.path);
// Fetch children if not cached
if(!S._dirCache[item.path]){
try{
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(item.path)}`);
S._dirCache[item.path]=data.entries||[];
}catch(e2){S._dirCache[item.path]=[];}
}
renderFileTree();
}
};
}else{
el.onclick=async()=>openFile(item.path);
}
container.appendChild(el);
// Render children if directory is expanded
if(item.type==='dir'&&S._expandedDirs.has(item.path)){
const children=S._dirCache[item.path]||[];
if(children.length){
_renderTreeItems(container, children, depth+1);
}else{
const empty=document.createElement('div');
empty.className='file-item file-empty';
empty.style.paddingLeft=(8+(depth+1)*16)+'px';
empty.textContent='(empty)';
container.appendChild(empty);
}
}
} }
} }

View File

@@ -9,9 +9,17 @@ async function api(path,opts={}){
async function loadDir(path){ async function loadDir(path){
if(!S.session)return; if(!S.session)return;
try{ try{
if(!path||path==='.'){ S._dirCache={}; if(S._expandedDirs)S._expandedDirs=new Set(); }
S.currentDir=path||'.'; S.currentDir=path||'.';
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`); const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
S.entries=data.entries||[];renderBreadcrumb();renderFileTree(); S.entries=data.entries||[];renderBreadcrumb();renderFileTree();
if(typeof clearPreview==='function'){
if(typeof _previewDirty!=='undefined'&&_previewDirty){
if(confirm('You have unsaved changes in the preview. Discard and navigate?'))clearPreview();
}else{
clearPreview();
}
}
}catch(e){console.warn('loadDir',e);} }catch(e){console.warn('loadDir',e);}
} }