diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b62c98e..ab78782c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.143] — 2026-05-26 — Release DO (stage-batch25 — single-PR workspace:// markdown scheme) + +### Added + +- Chat markdown links using `workspace://path/to/file` now open the target in the workspace preview pane instead of navigating away from the WebUI. Renderable via both the settled `renderMd()` and live `streaming-markdown` paths. Workspace path existence is verified via `/api/list` before opening; missing files surface a `file_open_failed` status toast. (#2881, #2938) + ## [v0.51.142] — 2026-05-26 — Release DN (stage-batch24 — 4-PR fresh-today batch) ### Added diff --git a/README.md b/README.md index 46912630..7120dfa7 100644 --- a/README.md +++ b/README.md @@ -479,6 +479,7 @@ Production data and real cron jobs are never touched. Current snapshot: - Directory tree with expand/collapse (single-click toggles, double-click navigates) - Breadcrumb navigation with clickable path segments - Preview text, code, Markdown (rendered), and images inline +- Chat links using `workspace://path/to/file` open files in the right-side preview pane - Edit, create, delete, and rename files; create folders - Binary file download (auto-detected from server) - File preview auto-closes on directory navigation (with unsaved-edit guard) diff --git a/static/messages.js b/static/messages.js index 86245d57..2d72547b 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1083,8 +1083,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // Raw file:// anchors are rewritten to /api/media before the user can click them. const _SMD_SAFE_URL_RE=/^(?:https?:|mailto:|tel:|\/|#|\?|\.|api)/i; const _SMD_SAFE_IMG_URL_RE=/^(?:https?:|mailto:|tel:|\/|#|\?|\.)/i; - function _smdFileHref(raw){ + function _smdLinkHref(raw){ const href=String(raw||''); + if(/^workspace:\/\//i.test(href)){ + try{ + const rel=decodeURIComponent(href.replace(/^workspace:\/\//i,'')).replace(/^~\//,'').replace(/^\.\//,''); + return '#workspace='+encodeURIComponent(rel); + }catch(_){ + return '#'; + } + } if(!/^file:\/\//i.test(href)) return href; try{ const path=decodeURIComponent(href.replace(/^file:\/\//i,'')); @@ -1093,12 +1101,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return 'api/media?path='+encodeURIComponent(href.replace(/^file:\/\//i,''))+'&inline=1'; } } + function _smdFileHref(raw){ + return _smdLinkHref(raw); + } function _sanitizeSmdLinks(root){ if(!root||!root.querySelectorAll) return; const _a=root.querySelectorAll('a[href]'); for(let i=0;i<_a.length;i++){ const n=_a[i],v=n.getAttribute('href')||''; - if(/^file:\/\//i.test(v)){n.setAttribute('href',_smdFileHref(v));continue;} + if(/^(file|workspace):\/\//i.test(v)){n.setAttribute('href',_smdLinkHref(v));continue;} if(!_SMD_SAFE_URL_RE.test(v)){n.removeAttribute('href');n.setAttribute('data-blocked-scheme','1');} } const _im=root.querySelectorAll('img[src]'); @@ -1209,8 +1220,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const isHref=window.smd&&attr===window.smd.HREF; const isSrc=window.smd&&attr===window.smd.SRC; const safeUrl=isSrc?_SMD_SAFE_IMG_URL_RE:_SMD_SAFE_URL_RE; - if(isHref&&/^file:\/\//i.test(String(value||''))){ - baseSetAttr(data,attr,_smdFileHref(value)); + if(isHref&&/^(file|workspace):\/\//i.test(String(value||''))){ + baseSetAttr(data,attr,_smdLinkHref(value)); return; } if((isHref||isSrc)&&!safeUrl.test(String(value||''))){ diff --git a/static/ui.js b/static/ui.js index 8ce7745f..a4bab419 100644 --- a/static/ui.js +++ b/static/ui.js @@ -628,6 +628,16 @@ function _closeImgLightbox(lb) { document.addEventListener('click', e => { if(!e.target || !e.target.closest) return; + const workspaceLink=e.target.closest('a[href^="#workspace="]'); + if(workspaceLink){ + e.preventDefault(); + const href=workspaceLink.getAttribute('href')||''; + try{ + const rel=decodeURIComponent(href.slice('#workspace='.length)); + if(rel && typeof openArtifactPath==='function') openArtifactPath(rel); + }catch(_){} + return; + } // Message-attached images (already wired since v0.50.x). let img = e.target.closest('.msg-media-img'); if(img){ _openImgLightbox(img); return; } @@ -3112,7 +3122,7 @@ function renderMd(raw){ t=t.replace(/\x00C(\d+)\x00/g,(_,i)=>_code_stash[+i]); // Stash [label](url) links before autolink so the URL in href= is not re-linked const _link_stash=[]; - t=t.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)}`);return `\x00L${_link_stash.length-1}\x00`;}); + t=t.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|workspace:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)}`);return `\x00L${_link_stash.length-1}\x00`;}); t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `${esc(clean)}${trail}`;}); t=t.replace(/\x00L(\d+)\x00/g,(_,i)=>_link_stash[+i]); t=t.replace(/\x00G(\d+)\x00/g,(_,i)=>_img_stash[+i]); @@ -3205,7 +3215,7 @@ function renderMd(raw){ // Stash existing tags first to avoid re-linking already-linked URLs. const _a_stash=[]; s=s.replace(/(]*>[\s\S]*?<\/a>)/g,m=>{_a_stash.push(m);return `\x00A${_a_stash.length-1}\x00`;}); - s=s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,label,url)=>`${esc(label)}`); + s=s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|workspace:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,label,url)=>`${esc(label)}`); s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]); // Restore raw
only after markdown rewrites so literal preformatted
// content stays placeholder-protected, then let the sanitizer normalize tags.
@@ -3223,6 +3233,14 @@ function renderMd(raw){
}
function _markdownHref(raw){
const href=String(raw||'').replace(/"/g,'%22');
+ if(/^workspace:\/\//i.test(href)){
+ try{
+ const rel=decodeURIComponent(href.replace(/^workspace:\/\//i,'')).replace(/^~\//,'').replace(/^\.\//,'');
+ return '#workspace='+encodeURIComponent(rel);
+ }catch(_){
+ return '#';
+ }
+ }
if(/^file:\/\//i.test(href)){
try{
const path=decodeURIComponent(href.replace(/^file:\/\//i,''));
diff --git a/static/workspace.js b/static/workspace.js
index b53c47fe..603bdc31 100644
--- a/static/workspace.js
+++ b/static/workspace.js
@@ -255,10 +255,29 @@ function renderSessionArtifacts(){
root.innerHTML = items.map(item => ``).join('');
}
-function openArtifactPath(path){
+async function _workspacePathExists(path){
+ if(!S.session||!path) return false;
+ const parts=String(path).split('/').filter(Boolean);
+ const name=parts.pop();
+ if(!name) return false;
+ const dir=parts.length?parts.join('/'):'.';
+ const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dir)}`);
+ return (data.entries||[]).some(entry=>entry&&((entry.path===path)||entry.name===name));
+}
+
+async function openArtifactPath(path){
if(!path) return;
switchWorkspacePanelTab('files');
const rel = path.replace(/^~\//,'').replace(/^\.\//,'');
+ try{
+ if(!(await _workspacePathExists(rel))){
+ setStatus(t('file_open_failed'));
+ return;
+ }
+ }catch(_){
+ setStatus(t('file_open_failed'));
+ return;
+ }
openFile(rel);
}
diff --git a/tests/test_issue2881_workspace_chat_links.py b/tests/test_issue2881_workspace_chat_links.py
new file mode 100644
index 00000000..c8f5aa89
--- /dev/null
+++ b/tests/test_issue2881_workspace_chat_links.py
@@ -0,0 +1,102 @@
+"""Regression coverage for workspace:// chat links opening workspace preview (#2881)."""
+
+from __future__ import annotations
+
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).parent.parent.resolve()
+UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
+MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
+NODE = shutil.which("node")
+
+pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
+
+_DRIVER_SRC = r"""
+const fs = require('fs');
+const src = fs.readFileSync(process.argv[2], 'utf8');
+global.window = {};
+global.document = { createElement: () => ({ innerHTML: '', textContent: '' }) };
+const esc = s => String(s ?? '').replace(/[&<>"']/g, c => (
+ {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
+const _SVG_EXTS=/\.svg$/i;
+const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm)$/i;
+const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i;
+
+function extractFunc(name) {
+ const re = new RegExp('function\\s+' + name + '\\s*\\(');
+ const start = src.search(re);
+ if (start < 0) throw new Error(name + ' not found');
+ let i = src.indexOf('{', start);
+ let depth = 1; i++;
+ while (depth > 0 && i < src.length) {
+ if (src[i] === '{') depth++;
+ else if (src[i] === '}') depth--;
+ i++;
+ }
+ return src.slice(start, i);
+}
+eval(extractFunc('_matchBacktickFenceLine'));
+eval(extractFunc('_isBacktickFenceClose'));
+eval(extractFunc('renderMd'));
+
+let buf = '';
+process.stdin.on('data', c => { buf += c; });
+process.stdin.on('end', () => { process.stdout.write(renderMd(buf)); });
+"""
+
+
+@pytest.fixture(scope="module")
+def driver_path(tmp_path_factory):
+ path = tmp_path_factory.mktemp("issue2881_renderer") / "driver.js"
+ path.write_text(_DRIVER_SRC, encoding="utf-8")
+ return str(path)
+
+
+def _render(driver_path: str, markdown: str) -> str:
+ result = subprocess.run(
+ [NODE, driver_path, str(REPO_ROOT / "static" / "ui.js")],
+ input=markdown,
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(result.stderr)
+ return result.stdout
+
+
+def test_render_md_rewrites_workspace_links_to_internal_anchor(driver_path):
+ html = _render(driver_path, "[Open plan](workspace://notes/plan.md)")
+
+ assert 'href="#workspace=notes%2Fplan.md"' in html
+ assert "workspace://notes/plan.md" not in html
+ assert ">Open plan" in html
+
+
+def test_render_md_does_not_autolink_raw_workspace_urls(driver_path):
+ html = _render(driver_path, "Open workspace://notes/plan.md manually")
+
+ assert '