Merge remote-tracking branch 'origin/master' into paperclip/pr-6222

This commit is contained in:
Frank Song committed 2026-07-21 12:42:37 +08:00
commit c1ce5a88a7
15 files changed
+343 -22

No files matched your search

+2
View File
@@ -19,6 +19,8 @@
### Fixed
- **Workspace Artifacts pane: long filenames are fully readable and long parent directories stay inside the drawer.** A long artifact filename used to be ellipsized (so you couldn't tell files apart), and an unusually long immediate-parent directory could expand the Artifacts drawer horizontally and bleed past its edge. The list now shows the filename first with parent-preserving path truncation, and the directory tail is width-capped (`min-width: 0` + `overflow: hidden` + `text-overflow: ellipsis`) so it clips inside the card instead of blowing out the drawer. Adds behavioral/locale coverage for the artifact path lifecycle. Thanks @rodboev. (#6078, #6067)
- **The Kanban "New task" modal is reachable on phones again.** On a narrow viewport, a tall create-task modal was vertically centered, pushing its top (the title and first input) above the top of the screen with no way to scroll back to it. The mobile overlay now uses `align-items: safe center` and `overflow-y: auto`, so an overflowing modal keeps its top on-screen and scrolls as needed; desktop and wider layouts are unchanged. Thanks @jpalazz2. (#6301)
- **Concurrent chats under different profiles can no longer cross the profile boundary mid-turn.** In a single WebUI process, two chats streaming at once could resolve different Hermes profiles, but agent construction and later prompt/config/skill reads still depended on process-global `HERMES_HOME` and globally-patched skill-module paths after the env lock was released — so a second profile's turn could overwrite those globals mid-first-turn and bleed one profile's `SOUL.md`/skills/config into another's chat. The resolved profile home is now bound through the agent's per-thread context-local override for the entire streaming turn (and detached worker scope) and reset in the run's terminal `finally`, so concurrent profiles stay isolated even if the process globals are clobbered mid-run. Checkpoint saves skip the skill lock (no deadlock) and the capability check rejects globally-patched module state (no leak), with a serialized snapshot/patch/restore fallback that keeps older agents safe. Thanks @starship-s. (#5877, #5567, #2023)
+1
View File
@@ -464,6 +464,7 @@ const LOCALES = {
workspace_close_preview: 'Close preview',
workspace_files_tab: 'Files',
workspace_artifacts_tab: 'Artifacts',
workspace_artifact_source_session: 'session',
workspace_hidden_files_visible: 'hidden visible',
workspace_hidden_files_visible_title: 'Hidden files are visible — click for options',
workspace_options: 'Workspace options',
+5 -3
View File
@@ -6432,9 +6432,11 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
.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-name{font-family:'SF Mono',ui-monospace,monospace;font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text);}
.workspace-artifact-dir{font-family:'SF Mono',ui-monospace,monospace;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--muted);margin-top:1px;}
.workspace-artifact-meta{margin-top:3px;color:var(--muted);font-size:11px;}
.workspace-artifact-filename{font-family:'SF Mono',ui-monospace,monospace;font-size:12px;overflow-wrap:anywhere;}
.workspace-artifact-directory{display:flex;min-width:0;overflow:hidden;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;font-size:11px;color:var(--muted);}
.workspace-artifact-directory-head{min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis;}
.workspace-artifact-directory-tail{flex:0 0 auto;min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;}
.workspace-artifact-meta{display:inline-block;margin-top:4px;padding:1px 5px;border:1px solid var(--border);border-radius:999px;color:var(--muted);font-size:11px;line-height:1.3;}
.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;}
+19 -9
View File
@@ -586,16 +586,26 @@ function renderSessionArtifacts(){
if(normWs && p.startsWith(normWs)) return p.slice(normWs.length);
return p;
};
const splitArtifactDisplayPath = (path) => {
const slash = path.lastIndexOf('/');
if(slash < 0) return {name: path, head: '', tail: ''};
const directory = path.slice(0, slash + 1);
const parentSlash = directory.lastIndexOf('/', directory.length - 2);
return {
name: path.slice(slash + 1),
head: directory.slice(0, parentSlash + 1),
tail: directory.slice(parentSlash + 1),
};
};
root.innerHTML = items.map(item => {
const dPath = displayPath(item.path);
const idx = dPath.lastIndexOf('/');
const fileName = idx >= 0 ? dPath.slice(idx + 1) : dPath;
const dirPath = idx >= 0 ? dPath.slice(0, idx) : '';
return `<button type="button" class="workspace-artifact-item" data-artifact-path="${esc(item.path)}" onclick="openArtifactPath(this.dataset.artifactPath)">
<div class="workspace-artifact-name">${esc(fileName)}</div>
${dirPath ? `<div class="workspace-artifact-dir">${esc(dirPath)}</div>` : ''}
<div class="workspace-artifact-meta">${esc(item.source || 'session')}</div>
</button>`;
const path = displayPath(item.path);
const parts = splitArtifactDisplayPath(path);
const directory = (parts.head || parts.tail)
? `<div class="workspace-artifact-directory"><span class="workspace-artifact-directory-head">${esc(parts.head)}</span><span class="workspace-artifact-directory-tail">${esc(parts.tail)}</span></div>`
: '';
const source = item.source ? esc(item.source) : esc(t('workspace_artifact_source_session') || 'session');
const sourceAttrs = item.source ? '' : ' data-i18n="workspace_artifact_source_session"';
return `<button type="button" class="workspace-artifact-item" title="${esc(path)}" data-artifact-path="${esc(item.path)}" onclick="openArtifactPath(this.dataset.artifactPath)"><div class="workspace-artifact-filename">${esc(parts.name)}</div>${directory}<div class="workspace-artifact-meta"${sourceAttrs}>${source}</div></button>`;
}).join('');
}
+4 -1
View File
@@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
REPO = Path(__file__).resolve().parent.parent
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
PROFILE_CONCEPT_FALLBACK_KEYS = {
*PROFILE_CONCEPT_KEYS,
"workspace_artifact_source_session",
}
def read(path: Path) -> str:
+4 -1
View File
@@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
REPO = Path(__file__).resolve().parent.parent
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
PROFILE_CONCEPT_FALLBACK_KEYS = {
*PROFILE_CONCEPT_KEYS,
"workspace_artifact_source_session",
}
def read(path: Path) -> str:
+276
View File
@@ -0,0 +1,276 @@
import os
from pathlib import Path
import re
import sys
import pytest
ROOT = Path(os.environ.get("ISSUE6067_REPO_ROOT") or Path(__file__).resolve().parents[1])
sys.path.insert(0, str(ROOT / "tests"))
from _layout_helpers import assert_layout_sane
WORKSPACE_JS = (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
EXPECT_VISIBLE = os.environ.get("ISSUE6067_EXPECT_VISIBLE", "1") != "0"
HEADLESS = os.environ.get("ISSUE6067_HEADLESS", "1") != "0"
SCREENSHOT_PATH = os.environ.get("ISSUE6067_SCREENSHOT_PATH")
ARTIFACT_CASES = [
{"path": "users.py", "source": "patch", "display": "users.py", "name": "users.py", "head": "", "tail": ""},
{"path": "src/api/routes/users.py", "source": "tool", "display": "src/api/routes/users.py", "name": "users.py", "head": "src/api/", "tail": "routes/"},
{"path": "dir/foo.py", "source": "single", "display": "dir/foo.py", "name": "foo.py", "head": "", "tail": "dir/"},
{"path": "src/project/app/api/routes/handlers/users.py", "source": "", "display": "src/project/app/api/routes/handlers/users.py", "name": "users.py", "head": "src/project/app/api/routes/", "tail": "handlers/"},
{"path": "/file.txt", "source": "root", "display": "/file.txt", "name": "file.txt", "head": "/", "tail": ""},
{"path": "dir/", "source": "trail", "display": "dir/", "name": "", "head": "", "tail": "dir/"},
{"path": "", "source": "empty", "display": "", "name": "", "head": "", "tail": ""},
{"path": "src//users.py", "source": "gap", "display": "src//users.py", "name": "users.py", "head": "src/", "tail": "/"},
{"path": "src/lastIndexOf/users.py", "source": "literal", "display": "src/lastIndexOf/users.py", "name": "users.py", "head": "src/", "tail": "lastIndexOf/"},
{"path": "/workspace/über/<unsafe>/routes/quote&.py", "source": "<source>&", "display": "über/<unsafe>/routes/quote&.py", "name": "quote&.py", "head": "über/<unsafe>/", "tail": "routes/"},
{"path": "2026-07-20-quarterly-revenue-analysis-with-regional-breakdown-and-confidence-intervals/summary.json", "source": "", "display": "2026-07-20-quarterly-revenue-analysis-with-regional-breakdown-and-confidence-intervals/summary.json", "name": "summary.json", "head": "", "tail": "2026-07-20-quarterly-revenue-analysis-with-regional-breakdown-and-confidence-intervals/"},
]
def _function(source, name):
start = source.index(f"function {name}(")
brace = source.index("{", start)
depth = 0
for pos in range(brace, len(source)):
if source[pos] == "{":
depth += 1
elif source[pos] == "}":
depth -= 1
if depth == 0:
return source[start:pos + 1]
raise AssertionError(f"could not extract {name}")
def _locale_blocks():
matches = list(re.finditer(r"^ ('[^']+'|[A-Za-z][A-Za-z0-9-]*): \{$", I18N_JS, re.MULTILINE))
end = I18N_JS.index("\n};", matches[-1].start())
return {
match.group(1).strip("'"): I18N_JS[match.start() : (matches[index + 1].start() if index + 1 < len(matches) else end)]
for index, match in enumerate(matches)
}
def _filename_visible(locator) -> bool:
return locator.evaluate(
"""node => {
const range = document.createRange();
range.selectNodeContents(node);
const text = range.getBoundingClientRect();
const box = node.getBoundingClientRect();
return text.left >= box.left - 1 && text.right <= box.right + 1;
}"""
)
def _path_suffix_visible(locator, suffix: str) -> bool:
return locator.evaluate(
"""(node, suffix) => {
const textNode = node.firstChild;
if(!textNode || textNode.nodeType !== Node.TEXT_NODE) return false;
const value = textNode.textContent || '';
const start = value.lastIndexOf(suffix);
if(start < 0) return false;
const range = document.createRange();
range.setStart(textNode, start);
range.setEnd(textNode, start + suffix.length);
const text = range.getBoundingClientRect();
const box = node.getBoundingClientRect();
return text.left >= box.left - 1 && text.right <= box.right + 1;
}""",
suffix,
)
def _directory_text_gap(locator) -> float:
return locator.evaluate(
"""node => {
const head = node.querySelector('.workspace-artifact-directory-head');
const tail = node.querySelector('.workspace-artifact-directory-tail');
if(!head || !tail) return Number.POSITIVE_INFINITY;
const range = document.createRange();
range.selectNodeContents(head);
const text = range.getBoundingClientRect();
const tailBox = tail.getBoundingClientRect();
return tailBox.left - text.right;
}"""
)
def test_issue6067_session_source_key_is_english_fallback_owned():
locale_blocks = _locale_blocks()
assert re.search(r"\bworkspace_artifact_source_session:\s*'session'", locale_blocks["en"])
for locale, block in locale_blocks.items():
if locale == "en":
continue
assert not re.search(r"\bworkspace_artifact_source_session:\s*'", block), (
f"workspace_artifact_source_session must be absent from non-English locale {locale!r}"
)
assert "_locale[key] ?? LOCALES.en[key]" in I18N_JS
def test_issue6067_artifact_filenames_remain_visible_across_artifact_widths():
try:
from playwright.sync_api import sync_playwright
except Exception: # pragma: no cover - dependency missing path
pytest.skip("playwright is unavailable; run manual local browser proof for issue #6067")
items = [{"path": case["path"], "source": case["source"]} for case in ARTIFACT_CASES]
renderer = _function(WORKSPACE_JS, "renderSessionArtifacts")
css = STYLE_CSS.replace("</style>", "")
harness = f"""
<style>{css}</style>
<style>
body {{
margin: 0;
background: #141327;
color: #efe7dd;
font-family: Inter, system-ui, sans-serif;
}}
.issue6067-proof-shell {{
min-height: 100vh;
display: flex;
align-items: stretch;
justify-content: space-between;
}}
.issue6067-proof-main {{
flex: 1 1 auto;
padding: 56px 48px;
font-size: 24px;
}}
.issue6067-proof-panel {{
width: 360px;
min-width: 360px;
border-left: 1px solid rgba(255,255,255,.08);
background: #17162b;
}}
.issue6067-proof-tabs {{
display: flex;
padding: 18px 16px 0;
gap: 12px;
}}
.issue6067-proof-tab {{
flex: 1 1 0;
padding: 12px 16px;
border: 1px solid rgba(255,255,255,.12);
border-radius: 14px;
text-align: center;
color: rgba(239,231,221,.72);
}}
.issue6067-proof-tab.active {{
color: #efe7dd;
border-color: rgba(255,255,255,.18);
}}
#workspaceArtifacts {{
width: 100%;
box-sizing: border-box;
}}
</style>
<div class="issue6067-proof-shell">
<div class="issue6067-proof-main">Artifacts proof fixture for issue #6067</div>
<div class="issue6067-proof-panel rightpanel" data-active-tab="artifacts">
<div class="issue6067-proof-tabs">
<div class="issue6067-proof-tab">Files</div>
<div id="workspaceArtifactsTab" class="issue6067-proof-tab active">Artifacts</div>
</div>
<div id="workspaceArtifacts" class="workspace-artifacts"></div>
</div>
</div>
<span id="workspaceArtifactsCount"></span>
<script>
const S = {{session: {{workspace: '/workspace'}}, artifacts: {items!r}}};
const opened = [];
const $ = id => document.getElementById(id);
const esc = value => String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
const t = key => key === 'workspace_artifact_source_session' ? 'session' : key;
const collectSessionArtifacts = () => S.artifacts;
const openArtifactPath = path => opened.push(path);
{renderer}
renderSessionArtifacts();
</script>
"""
with sync_playwright() as playwright:
browser = playwright.chromium.launch(
headless=HEADLESS,
args=["--no-sandbox", "--disable-dev-shm-usage"],
)
page = browser.new_page(viewport={"width": 1024, "height": 600})
page.set_content(harness)
if SCREENSHOT_PATH:
page.screenshot(path=SCREENSHOT_PATH)
page.set_viewport_size({"width": 1024, "height": 1400})
buttons = page.locator(".workspace-artifact-item")
assert buttons.count() == len(ARTIFACT_CASES)
assert page.locator(".workspace-artifact-meta").all_text_contents() == [
"patch", "tool", "single", "session", "root", "trail", "empty", "gap", "literal", "<source>&", "session"
]
if EXPECT_VISIBLE:
assert page.locator(".workspace-artifact-meta[data-i18n='workspace_artifact_source_session']").count() == 2
for index, case in enumerate(ARTIFACT_CASES):
button = buttons.nth(index)
assert button.get_attribute("data-artifact-path") == case["path"]
assert button.get_attribute("title") == case["display"]
assert button.locator(".workspace-artifact-filename").inner_text() == case["name"]
directory = button.locator(".workspace-artifact-directory")
if case["head"] or case["tail"]:
assert directory.count() == 1
assert button.locator(".workspace-artifact-directory-head").inner_text() == case["head"]
assert button.locator(".workspace-artifact-directory-tail").inner_text() == case["tail"]
else:
assert directory.count() == 0
assert "<unsafe>" in buttons.nth(9).get_attribute("title")
else:
assert page.locator(".workspace-artifact-meta[data-i18n='workspace_artifact_source_session']").count() == 0
assert page.locator(".workspace-artifact-path").all_text_contents() == [
case["display"] for case in ARTIFACT_CASES
]
for width in (180, 360, 519, 521):
page.locator("#workspaceArtifacts").evaluate("(node, nextWidth) => node.style.width = nextWidth + 'px'", width)
if EXPECT_VISIBLE:
for index, case in enumerate(ARTIFACT_CASES):
if case["name"]:
assert _filename_visible(buttons.nth(index).locator(".workspace-artifact-filename"))
assert buttons.nth(4).locator(".workspace-artifact-directory-head").inner_text() == "/"
assert buttons.nth(7).locator(".workspace-artifact-directory-tail").inner_text() == "/"
deep_head = buttons.nth(3).locator(".workspace-artifact-directory-head")
assert deep_head.evaluate("node => node.scrollWidth >= node.clientWidth")
if width in (360, 521):
short_directory = buttons.nth(1).locator(".workspace-artifact-directory")
short_head = buttons.nth(1).locator(".workspace-artifact-directory-head")
assert short_head.evaluate("node => node.scrollWidth <= node.clientWidth + 1")
assert _directory_text_gap(short_directory) <= 2
elif width == 180:
clipped_path = buttons.nth(1).locator(".workspace-artifact-path")
assert not _path_suffix_visible(clipped_path, "users.py")
assert_layout_sane(page, "#workspaceArtifacts", checks=["overlap", "container-escape", "degenerate", "raw-string"])
page.set_viewport_size({"width": 390, "height": 844})
page.locator(".issue6067-proof-panel").evaluate(
"node => { node.style.width = '300px'; node.classList.add('mobile-open'); }"
)
page.locator("#workspaceArtifacts").evaluate("node => node.style.removeProperty('width')")
long_row = buttons.nth(10)
long_tail = long_row.locator(".workspace-artifact-directory-tail")
for selector in ("#workspaceArtifacts", ".workspace-artifact-item"):
assert page.locator(selector).nth(10 if selector != "#workspaceArtifacts" else 0).evaluate(
"node => node.scrollWidth === node.clientWidth"
)
assert long_row.locator(".workspace-artifact-filename").inner_text() == "summary.json"
assert _filename_visible(long_row.locator(".workspace-artifact-filename"))
assert long_tail.evaluate(
"node => node.scrollWidth > node.clientWidth && getComputedStyle(node).textOverflow === 'ellipsis'"
)
assert_layout_sane(page, "#workspaceArtifacts", checks=["overlap", "clip", "container-escape", "degenerate", "raw-string"])
for index in range(buttons.count()):
buttons.nth(index).focus()
assert page.evaluate("() => document.activeElement.classList.contains('workspace-artifact-item')")
buttons.nth(index).click()
assert page.evaluate("opened") == [case["path"] for case in ARTIFACT_CASES]
browser.close()
+4 -1
View File
@@ -14,7 +14,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
REPO = Path(__file__).resolve().parent.parent
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
PROFILE_CONCEPT_FALLBACK_KEYS = {
*PROFILE_CONCEPT_KEYS,
"workspace_artifact_source_session",
}
def read(path: Path) -> str:
+4 -1
View File
@@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
REPO = Path(__file__).resolve().parent.parent
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
PROFILE_CONCEPT_FALLBACK_KEYS = {
*PROFILE_CONCEPT_KEYS,
"workspace_artifact_source_session",
}
def read(path: Path) -> str:
+4 -1
View File
@@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
REPO = Path(__file__).resolve().parent.parent
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
PROFILE_CONCEPT_FALLBACK_KEYS = {
*PROFILE_CONCEPT_KEYS,
"workspace_artifact_source_session",
}
def read(path: Path) -> str:
+4 -1
View File
@@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
REPO = Path(__file__).resolve().parent.parent
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
PROFILE_CONCEPT_FALLBACK_KEYS = {
*PROFILE_CONCEPT_KEYS,
"workspace_artifact_source_session",
}
def read(path: Path) -> str:
+4 -1
View File
@@ -4,7 +4,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
REPO = Path(__file__).resolve().parent.parent
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
PROFILE_CONCEPT_FALLBACK_KEYS = {
*PROFILE_CONCEPT_KEYS,
"workspace_artifact_source_session",
}
def read(path: Path) -> str:
+4 -1
View File
@@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
REPO = Path(__file__).resolve().parent.parent
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
PROFILE_CONCEPT_FALLBACK_KEYS = {
*PROFILE_CONCEPT_KEYS,
"workspace_artifact_source_session",
}
def read(path: Path) -> str:
+4 -1
View File
@@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
REPO = Path(__file__).resolve().parent.parent
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
PROFILE_CONCEPT_FALLBACK_KEYS = {
*PROFILE_CONCEPT_KEYS,
"workspace_artifact_source_session",
}
def read(path: Path) -> str:
+4 -1
View File
@@ -4,7 +4,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
REPO = Path(__file__).resolve().parent.parent
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
PROFILE_CONCEPT_FALLBACK_KEYS = {
*PROFILE_CONCEPT_KEYS,
"workspace_artifact_source_session",
}
def read(path: Path) -> str: