mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Cherry-picked via 3-way apply onto stage HEAD. Resolved workspace.js conflict: kept master's #2716 sessionId-capture stale-session guard (closure-scoped sessionId check after await), AND added PR's renderSessionArtifacts() call to refresh the new Artifacts tab when the file tree updates. Wrapped in typeof check for defense. Co-authored-by: AJV20 <abdielvc@me.com>
This commit is contained in:
@@ -1319,8 +1319,13 @@
|
||||
<button class="panel-icon-btn close-preview has-tooltip has-tooltip--bottom" id="btnClearPreview" data-tooltip="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="workspace-panel-tabs" role="tablist" aria-label="Workspace panel views">
|
||||
<button class="workspace-panel-tab active" id="workspaceFilesTab" type="button" onclick="switchWorkspacePanelTab('files')" role="tab" aria-selected="true">Files</button>
|
||||
<button class="workspace-panel-tab" id="workspaceArtifactsTab" type="button" onclick="switchWorkspacePanelTab('artifacts')" role="tab" aria-selected="false">Artifacts <span id="workspaceArtifactsCount" class="workspace-artifacts-count">0</span></button>
|
||||
</div>
|
||||
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
|
||||
<div class="file-tree" id="fileTree"></div>
|
||||
<div class="workspace-artifacts" id="workspaceArtifacts" hidden></div>
|
||||
<div id="wsEmptyState" style="display:none;flex:1;align-items:center;justify-content:center;padding:24px 16px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6"></div>
|
||||
<div class="preview-area" id="previewArea">
|
||||
<div class="preview-path" id="previewPath">
|
||||
|
||||
@@ -1543,6 +1543,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
S.toolCalls=INFLIGHT[activeSid].toolCalls;
|
||||
persistInflightState();
|
||||
|
||||
if(S.session&&S.session.session_id===activeSid&&typeof scheduleRenderSessionArtifacts==='function') scheduleRenderSessionArtifacts();
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
// NOTE: don't removeThinking() here — keep the thinking card visible
|
||||
// above the tool card so the turn reads top-to-bottom as:
|
||||
@@ -1589,6 +1590,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(d.duration!==undefined) tc.duration=d.duration;
|
||||
S.toolCalls=inflight.toolCalls;
|
||||
persistInflightState();
|
||||
if(S.session&&S.session.session_id===activeSid&&typeof scheduleRenderSessionArtifacts==='function') scheduleRenderSessionArtifacts();
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
appendLiveToolCard(tc);
|
||||
snapshotLiveTurn();
|
||||
@@ -1805,6 +1807,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
} else {
|
||||
S.toolCalls=hasMessageToolMetadata?[]:S.toolCalls.map(tc=>({...tc,done:true}));
|
||||
}
|
||||
if(typeof renderSessionArtifacts==='function') renderSessionArtifacts();
|
||||
if(typeof _copyActivityDisclosureState==='function'&&lastAsst){
|
||||
const assistantIdx=S.messages.indexOf(lastAsst);
|
||||
if(assistantIdx>=0) _copyActivityDisclosureState('live:'+streamId, 'assistant:'+assistantIdx);
|
||||
|
||||
@@ -827,6 +827,8 @@ async function loadSession(sid){
|
||||
// Clear the in-flight session marker now that this load has completed (#1060).
|
||||
if (_loadingSessionId === sid) _loadingSessionId = null;
|
||||
|
||||
if(typeof renderSessionArtifacts==='function') renderSessionArtifacts();
|
||||
|
||||
// ── Cross-channel handoff hint ──
|
||||
// After session fully loaded, check if this is a messaging session with
|
||||
// enough conversation rounds to warrant a handoff hint bar.
|
||||
|
||||
@@ -3839,6 +3839,19 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
|
||||
.detail-run-body{display:none;margin-top:6px;font-size:12px;color:var(--muted);white-space:pre-wrap;line-height:1.5;max-height:260px;overflow-y:auto;background:var(--sidebar);border:1px solid var(--border);border-radius:6px;padding:8px 10px;}
|
||||
.detail-run-item.open .detail-run-body{display:block;}
|
||||
.detail-run-body.expanded{max-height:none;overflow-y:visible;}
|
||||
.workspace-panel-tabs{display:flex;gap:4px;padding:6px 8px;border-bottom:1px solid var(--border);}
|
||||
.workspace-panel-tab{flex:1;border:1px solid transparent;background:transparent;color:var(--muted);border-radius:7px;padding:5px 8px;font-size:12px;cursor:pointer;}
|
||||
.workspace-panel-tab.active{background:var(--surface-subtle);color:var(--text);border-color:var(--border2);}
|
||||
.workspace-artifacts{flex:1;overflow:auto;padding:8px;}
|
||||
.workspace-artifact-empty{padding:16px 8px;color:var(--muted);font-size:12px;line-height:1.5;text-align:center;}
|
||||
.workspace-artifact-item{display:block;width:100%;text-align:left;background:var(--surface-subtle);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:6px;cursor:pointer;}
|
||||
.workspace-artifact-item:hover{border-color:var(--accent);}
|
||||
.workspace-artifact-path{font-family:'SF Mono',ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.workspace-artifact-meta{margin-top:3px;color:var(--muted);font-size:11px;}
|
||||
.workspace-artifacts-count{opacity:.65;font-size:11px;}
|
||||
.rightpanel[data-active-tab="artifacts"] .breadcrumb-bar,.rightpanel[data-active-tab="artifacts"] .file-tree,.rightpanel[data-active-tab="artifacts"] #wsEmptyState{display:none!important;}
|
||||
.rightpanel[data-active-tab="artifacts"] .workspace-artifacts{display:block;}
|
||||
.rightpanel[data-active-tab="files"] .workspace-artifacts{display:none!important;}
|
||||
.cron-run-usage-strip{display:inline-flex;align-items:center;gap:4px;margin-left:8px;color:var(--text-secondary);font-size:11px;opacity:.72;white-space:nowrap;}
|
||||
.cron-run-usage-footer{display:flex;margin:8px 0 0 0;padding-top:8px;border-top:1px solid var(--border-subtle);}
|
||||
.cron-item.active,.ws-row.active,.profile-card.active{background:var(--accent-bg);}
|
||||
|
||||
@@ -103,6 +103,148 @@ function _restoreExpandedDirs(){
|
||||
}catch(e){S._expandedDirs=new Set();}
|
||||
}
|
||||
|
||||
let _workspacePanelActiveTab = 'files';
|
||||
let _renderSessionArtifactsTimer = null;
|
||||
|
||||
function _setWorkspacePanelTabDataset(){
|
||||
const panel = document.querySelector('.rightpanel');
|
||||
if(panel) panel.dataset.activeTab = _workspacePanelActiveTab;
|
||||
}
|
||||
|
||||
function scheduleRenderSessionArtifacts(){
|
||||
if(_renderSessionArtifactsTimer) clearTimeout(_renderSessionArtifactsTimer);
|
||||
_renderSessionArtifactsTimer = setTimeout(()=>{
|
||||
_renderSessionArtifactsTimer = null;
|
||||
renderSessionArtifacts();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
if(typeof document !== 'undefined'){
|
||||
if(document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _setWorkspacePanelTabDataset, {once:true});
|
||||
else _setWorkspacePanelTabDataset();
|
||||
}
|
||||
|
||||
function switchWorkspacePanelTab(tab){
|
||||
_workspacePanelActiveTab = tab === 'artifacts' ? 'artifacts' : 'files';
|
||||
_setWorkspacePanelTabDataset();
|
||||
const filesTab = $('workspaceFilesTab');
|
||||
const artifactsTab = $('workspaceArtifactsTab');
|
||||
if(filesTab){
|
||||
filesTab.classList.toggle('active', _workspacePanelActiveTab === 'files');
|
||||
filesTab.setAttribute('aria-selected', _workspacePanelActiveTab === 'files' ? 'true' : 'false');
|
||||
}
|
||||
if(artifactsTab){
|
||||
artifactsTab.classList.toggle('active', _workspacePanelActiveTab === 'artifacts');
|
||||
artifactsTab.setAttribute('aria-selected', _workspacePanelActiveTab === 'artifacts' ? 'true' : 'false');
|
||||
}
|
||||
const artifacts = $('workspaceArtifacts');
|
||||
if(artifacts) artifacts.hidden = _workspacePanelActiveTab !== 'artifacts';
|
||||
if(_workspacePanelActiveTab === 'artifacts') renderSessionArtifacts();
|
||||
}
|
||||
|
||||
const ARTIFACT_IGNORE_RE = /(^|\/)(?:\.git|\.hg|\.svn|node_modules|\.venv|venv|__pycache__|dist|build|\.next|\.cache)(?:\/|$)/;
|
||||
// Canonical Hermes mutators plus MCP filesystem aliases that can create/edit files.
|
||||
const ARTIFACT_MUTATION_TOOLS = new Set(['write_file','patch','edit_file','create_file','mcp_filesystem_write_file','mcp_filesystem_edit_file']);
|
||||
|
||||
function _normalizeArtifactPath(path){
|
||||
if(!path) return '';
|
||||
path = String(path).trim().replace(/[\`"'<>),.;:]+$/g,'').replace(/^[\`"'(<]+/g,'');
|
||||
if(!path || path.length > 240 || path.includes('://')) return '';
|
||||
if(ARTIFACT_IGNORE_RE.test(path)) return '';
|
||||
if(!/[./]/.test(path)) return '';
|
||||
return path;
|
||||
}
|
||||
|
||||
function _artifactCandidatesFromText(text){
|
||||
if(!text || typeof text !== 'string') return [];
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const add = (path) => {
|
||||
path = _normalizeArtifactPath(path);
|
||||
if(!path || seen.has(path)) return;
|
||||
seen.add(path); out.push({path, kind:'diff'});
|
||||
};
|
||||
// Fallback text mining is intentionally narrow: only diff/patch fences imply
|
||||
// the session changed a file. Prose mentions such as "edited package.json" are
|
||||
// too noisy for an Artifacts list that should track write/edit outputs.
|
||||
const fenced = /```(?:diff|patch)\s*\n[\s\S]*?```/gi;
|
||||
let m;
|
||||
while((m = fenced.exec(text))){
|
||||
const block = m[0];
|
||||
const fm = block.match(/(?:^|\n)(?:\+\+\+|---)\s+(?:[ab]\/)?([^\n\t]+)/);
|
||||
if(fm) add(fm[1].trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function _artifactCandidatesFromToolCall(tc){
|
||||
if(!tc) return [];
|
||||
const name = String(tc.name || '').replace(/^functions\./,'');
|
||||
const args = tc.arguments || tc.args || tc.input || {};
|
||||
const result = tc.result || tc.output || tc.snippet || '';
|
||||
const out = [];
|
||||
const add = (path, source=name || 'tool') => {
|
||||
path = _normalizeArtifactPath(path);
|
||||
if(path) out.push({path, kind:source});
|
||||
};
|
||||
if(args && typeof args === 'object'){
|
||||
for(const key of ['path','file_path','source','destination']) add(args[key]);
|
||||
if(Array.isArray(args.paths)) args.paths.forEach(p=>add(p));
|
||||
if(Array.isArray(args.edits)) args.edits.forEach(e=>add(e&&e.path));
|
||||
}
|
||||
const resultText = typeof result === 'string' ? result : (result ? JSON.stringify(result) : '');
|
||||
// Tool results may include unified diffs from patch-style tools; scan those
|
||||
// narrowly after structured args so diff headers can still contribute paths.
|
||||
for(const a of _artifactCandidatesFromText(resultText)) out.push(a);
|
||||
if(!out.length && ARTIFACT_MUTATION_TOOLS.has(name)){
|
||||
const argsText = typeof args === 'string' ? args : JSON.stringify(args || {});
|
||||
for(const a of _artifactCandidatesFromText(argsText)) out.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectSessionArtifacts(){
|
||||
const items = [];
|
||||
const seen = new Set();
|
||||
const push = (path, source) => {
|
||||
path = _normalizeArtifactPath(path);
|
||||
if(!path || seen.has(path)) return;
|
||||
seen.add(path); items.push({path, source});
|
||||
};
|
||||
for(const tc of (S.toolCalls || [])){
|
||||
for(const a of _artifactCandidatesFromToolCall(tc)) push(a.path, a.kind || tc.name || 'tool');
|
||||
}
|
||||
for(const msg of (S.messages || [])){
|
||||
const text = msg && (msg.content || msg.text || msg.message || '');
|
||||
for(const a of _artifactCandidatesFromText(text)) push(a.path, a.kind);
|
||||
}
|
||||
return items.slice(0, 50);
|
||||
}
|
||||
|
||||
function renderSessionArtifacts(){
|
||||
const root = $('workspaceArtifacts');
|
||||
const count = $('workspaceArtifactsCount');
|
||||
if(!root) return;
|
||||
const items = collectSessionArtifacts();
|
||||
if(count) count.textContent = String(items.length);
|
||||
if(!S.session){
|
||||
root.innerHTML = '<div class="workspace-artifact-empty">Open a conversation to see files changed in this session.</div>';
|
||||
return;
|
||||
}
|
||||
if(!items.length){
|
||||
root.innerHTML = '<div class="workspace-artifact-empty">No artifacts detected yet. Files created or edited during this session will appear here.</div>';
|
||||
return;
|
||||
}
|
||||
root.innerHTML = items.map(item => `<button type="button" class="workspace-artifact-item" data-artifact-path="${esc(item.path)}" onclick="openArtifactPath(this.dataset.artifactPath)"><div class="workspace-artifact-path">${esc(item.path)}</div><div class="workspace-artifact-meta">${esc(item.source || 'session')}</div></button>`).join('');
|
||||
}
|
||||
|
||||
function openArtifactPath(path){
|
||||
if(!path) return;
|
||||
switchWorkspacePanelTab('files');
|
||||
const rel = path.replace(/^~\//,'').replace(/^\.\//,'');
|
||||
openFile(rel);
|
||||
}
|
||||
|
||||
async function loadDir(path){
|
||||
if(!S.session)return;
|
||||
const sessionId=S.session.session_id;
|
||||
@@ -115,6 +257,8 @@ async function loadDir(path){
|
||||
const data=await api(`/api/list?session_id=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(path)}`);
|
||||
if(!S.session||S.session.session_id!==sessionId)return;
|
||||
S.entries=data.entries||[];renderBreadcrumb();renderFileTree();
|
||||
// #2673 — refresh Artifacts tab when its source data (the file tree) updates.
|
||||
if(typeof renderSessionArtifacts==='function') renderSessionArtifacts();
|
||||
// Pre-fetch contents of restored expanded dirs so they render without a second click
|
||||
// (parallelized — avoids serial waterfall when multiple dirs are expanded)
|
||||
if(!path||path==='.'){
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from pathlib import Path
|
||||
|
||||
WORKSPACE_JS = Path("static/workspace.js").read_text(encoding="utf-8")
|
||||
SESSIONS_JS = Path("static/sessions.js").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = Path("static/messages.js").read_text(encoding="utf-8")
|
||||
INDEX_HTML = Path("static/index.html").read_text(encoding="utf-8")
|
||||
STYLE_CSS = Path("static/style.css").read_text(encoding="utf-8")
|
||||
CHANGELOG = Path("CHANGELOG.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_workspace_artifacts_tab_collects_session_files_and_previews_them():
|
||||
assert 'id="workspaceArtifactsTab"' in INDEX_HTML
|
||||
assert 'id="workspaceArtifacts"' in INDEX_HTML
|
||||
assert "function collectSessionArtifacts()" in WORKSPACE_JS
|
||||
assert "function _artifactCandidatesFromToolCall(tc)" in WORKSPACE_JS
|
||||
assert "ARTIFACT_IGNORE_RE" in WORKSPACE_JS
|
||||
assert "node_modules" in WORKSPACE_JS and "__pycache__" in WORKSPACE_JS
|
||||
assert "function renderSessionArtifacts()" in WORKSPACE_JS
|
||||
assert "function scheduleRenderSessionArtifacts()" in WORKSPACE_JS
|
||||
assert "function openArtifactPath(path)" in WORKSPACE_JS
|
||||
assert "openFile(rel);" in WORKSPACE_JS
|
||||
assert "Prose mentions" in WORKSPACE_JS
|
||||
assert "/(?:created|wrote|updated|edited|saved|modified)" not in WORKSPACE_JS
|
||||
assert "panel.dataset.activeTab = _workspacePanelActiveTab" in WORKSPACE_JS
|
||||
assert "renderSessionArtifacts();" in SESSIONS_JS
|
||||
assert "typeof scheduleRenderSessionArtifacts==='function'" in MESSAGES_JS
|
||||
assert "S.toolCalls=d.session.tool_calls.map" in MESSAGES_JS
|
||||
assert ".workspace-artifact-item" in STYLE_CSS
|
||||
|
||||
|
||||
def test_changelog_mentions_workspace_artifacts_tab():
|
||||
unreleased = CHANGELOG.split("## [v0.51.103]", 1)[0]
|
||||
assert "Artifacts tab" in unreleased
|
||||
Reference in New Issue
Block a user