feat: Sprint 18 — file preview auto-close, thinking display, workspace tree

- File preview auto-close: clearPreview() extracted as named function
  and called from loadDir(). Navigating directories (breadcrumbs, up
  button, folder clicks) now automatically closes the right panel
  file preview instead of leaving stale content visible.

- Thinking/reasoning display: assistant messages with structured content
  arrays containing type=thinking or type=reasoning blocks now render
  as collapsible gold-themed cards above the response text. Collapsed
  by default, click header to expand. Works with Claude extended thinking
  and o3 reasoning tokens when preserved in the message array.

- Workspace tree view (Issue #22): directories expand/collapse in-place
  with toggle arrows. Single-click toggles, double-click navigates
  (breadcrumb view). Subdirectory contents fetched lazily and cached.
  Indentation shows nesting depth. Empty directories show "(empty)".
  S._expandedDirs tracks open state, S._dirCache caches fetched entries.

Tests: 295 passed, 23 pre-existing failures, 0 regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Esquenazi
2026-04-03 04:33:24 -07:00
parent e7e09f217a
commit 67324cc3bc
4 changed files with 107 additions and 18 deletions

View File

@@ -43,14 +43,16 @@ $('importFileInput').onchange=async(e)=>{
}
};
// btnRefreshFiles is now panel-icon-btn in header (see HTML)
$('btnClearPreview').onclick=()=>{
$('previewArea').classList.remove('visible');
$('previewImg').src='';
$('previewMd').innerHTML='';
$('previewCode').textContent='';
$('previewPathText').textContent='';
$('fileTree').style.display='';
};
function clearPreview(){
const pa=$('previewArea');if(pa)pa.classList.remove('visible');
const pi=$('previewImg');if(pi)pi.src='';
const pm=$('previewMd');if(pm)pm.innerHTML='';
const pc=$('previewCode');if(pc)pc.textContent='';
const pp=$('previewPathText');if(pp)pp.textContent='';
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
$('modelSelect').onchange=async()=>{
if(!S.session)return;

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:hover{background:rgba(255,255,255,.07);color:var(--text);}
.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.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;}
@@ -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: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;}

View File

@@ -365,9 +365,20 @@ function renderMessages(){
for(let vi=0;vi<visWithIdx.length;vi++){
const {m,rawIdx}=visWithIdx[vi];
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 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';
row.dataset.msgIdx=rawIdx;
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(){
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';
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
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
// Name
const nameEl=document.createElement('span');
nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title='Double-click to rename';
// Inline rename on double-click
nameEl.ondblclick=(e)=>{
e.stopPropagation();
// For directories, double-click navigates (breadcrumb view)
if(item.type==='dir'){loadDir(item.path);return;}
const inp=document.createElement('input');
inp.className='file-rename-input';inp.value=item.name;
inp.onclick=(e2)=>e2.stopPropagation();
const finish=async(save)=>{
inp.onblur=null; // prevent double-call: Enter triggers blur after replaceWith
inp.onblur=null;
if(save){
const newName=inp.value.trim();
if(newName&&newName!==item.name){
@@ -773,6 +806,8 @@ function renderFileTree(){
session_id:S.session.session_id,path:item.path,new_name:newName
})});
showToast(`Renamed to ${newName}`);
// Invalidate cache and re-render
delete S._dirCache[S.currentDir];
await loadDir(S.currentDir);
}catch(err){showToast('Rename failed: '+err.message);}
}
@@ -789,7 +824,7 @@ function renderFileTree(){
};
el.appendChild(nameEl);
// Size -- only for files, right-aligned, shrinks but never wraps
// Size -- only for files
if(item.type==='file'&&item.size){
const sizeEl=document.createElement('span');
sizeEl.className='file-size';
@@ -797,16 +832,52 @@ function renderFileTree(){
el.appendChild(sizeEl);
}
// Delete button -- for files, shown on hover
// Delete button -- for files
if(item.type==='file'){
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);};
el.appendChild(del);
}
el.onclick=async()=>item.type==='dir'?loadDir(item.path):openFile(item.path);
box.appendChild(el);
if(item.type==='dir'){
// 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

@@ -12,6 +12,7 @@ async function loadDir(path){
S.currentDir=path||'.';
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
S.entries=data.entries||[];renderBreadcrumb();renderFileTree();
if(typeof clearPreview==='function')clearPreview();
}catch(e){console.warn('loadDir',e);}
}