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:
@@ -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;
|
||||||
|
|||||||
@@ -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;}
|
||||||
|
|||||||
91
static/ui.js
91
static/ui.js
@@ -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">💡</span><span class="thinking-card-label">Thinking</span><span class="thinking-card-toggle">▸</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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ async function loadDir(path){
|
|||||||
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')clearPreview();
|
||||||
}catch(e){console.warn('loadDir',e);}
|
}catch(e){console.warn('loadDir',e);}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user