diff --git a/static/index.html b/static/index.html index 222998e8..722d38f7 100644 --- a/static/index.html +++ b/static/index.html @@ -1319,8 +1319,13 @@ +
+ + +
+
diff --git a/static/messages.js b/static/messages.js index bfebb94d..1a4988d6 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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); diff --git a/static/sessions.js b/static/sessions.js index 2a4e199b..f57b5d9c 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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. diff --git a/static/style.css b/static/style.css index 5280cb4d..80793360 100644 --- a/static/style.css +++ b/static/style.css @@ -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);} diff --git a/static/workspace.js b/static/workspace.js index 03320f9a..f5972d06 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -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 = '
Open a conversation to see files changed in this session.
'; + return; + } + if(!items.length){ + root.innerHTML = '
No artifacts detected yet. Files created or edited during this session will appear here.
'; + return; + } + root.innerHTML = items.map(item => ``).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==='.'){ diff --git a/tests/test_issue2655_frontend.py b/tests/test_issue2655_frontend.py new file mode 100644 index 00000000..a7cf2098 --- /dev/null +++ b/tests/test_issue2655_frontend.py @@ -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