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