feat(chat): open workspace links in preview

This commit is contained in:
Frank Song
2026-05-25 21:48:52 +08:00
parent 48a2e79224
commit 7a52dec35d
6 changed files with 162 additions and 7 deletions

View File

@@ -3,6 +3,10 @@
## [Unreleased]
### 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.
## [v0.51.137] — 2026-05-25 — Release DI (stage-batch19 — 6-PR medium-risk batch)
### Added

View File

@@ -441,6 +441,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)

View File

@@ -1056,8 +1056,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,''));
@@ -1066,12 +1074,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]');
@@ -1182,8 +1193,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||''))){

View File

@@ -528,6 +528,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.src, img.alt); return; }
@@ -2960,7 +2970,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(`<a href="${_markdownHref(u)}" target="_blank" rel="noopener">${esc(lb)}</a>`);return `\x00L${_link_stash.length-1}\x00`;});
t=t.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|workspace:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`<a href="${_markdownHref(u)}" target="_blank" rel="noopener">${esc(lb)}</a>`);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 `<a href="${clean}" target="_blank" rel="noopener">${esc(clean)}</a>${trail}`;});
t=t.replace(/\x00L(\d+)\x00/g,(_,i)=>_link_stash[+i]);
t=t.replace(/\x00G(\d+)\x00/g,(_,i)=>_img_stash[+i]);
@@ -3053,7 +3063,7 @@ function renderMd(raw){
// Stash existing <a> tags first to avoid re-linking already-linked URLs.
const _a_stash=[];
s=s.replace(/(<a\b[^>]*>[\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)=>`<a href="${_markdownHref(url)}" target="_blank" rel="noopener">${esc(label)}</a>`);
s=s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|workspace:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,label,url)=>`<a href="${_markdownHref(url)}" target="_blank" rel="noopener">${esc(label)}</a>`);
s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]);
// Restore raw <pre> only after markdown rewrites so literal preformatted
// content stays placeholder-protected, then let the sanitizer normalize tags.
@@ -3071,6 +3081,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,''));

View File

@@ -255,10 +255,29 @@ function renderSessionArtifacts(){
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){
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);
}

View File

@@ -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 => (
{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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</a>" 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 '<a href="#workspace=' not in html
assert "workspace://notes/plan.md" in html
def test_workspace_link_click_delegate_opens_workspace_preview():
assert 'a[href^="#workspace="]' in UI_JS
assert "decodeURIComponent" in UI_JS
assert "openArtifactPath(rel)" in UI_JS
assert "async function openArtifactPath(path)" in (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
assert "/api/list?session_id=" in (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
assert "file_open_failed" in (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
def test_streaming_markdown_rewrites_workspace_links_before_sanitizing():
assert "function _smdLinkHref" in MESSAGES_JS
assert "workspace:\\/\\/" in MESSAGES_JS
assert "'#workspace='" in MESSAGES_JS
assert "_smdLinkHref(v)" in MESSAGES_JS
assert "_smdLinkHref(value)" in MESSAGES_JS