diff --git a/CHANGELOG.md b/CHANGELOG.md index deb84235..3c34c5f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/static/i18n.js b/static/i18n.js index 141fcfcc..57822182 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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', diff --git a/static/style.css b/static/style.css index 86829dbd..e5f2dfe6 100644 --- a/static/style.css +++ b/static/style.css @@ -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;} diff --git a/static/workspace.js b/static/workspace.js index 622e1e24..1e6c6a72 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -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 ``; + const path = displayPath(item.path); + const parts = splitArtifactDisplayPath(path); + const directory = (parts.head || parts.tail) + ? `
${esc(parts.head)}${esc(parts.tail)}
` + : ''; + 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 ``; }).join(''); } diff --git a/tests/test_chinese_locale.py b/tests/test_chinese_locale.py index c7f9be5d..92f187d2 100644 --- a/tests/test_chinese_locale.py +++ b/tests/test_chinese_locale.py @@ -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: diff --git a/tests/test_czech_locale.py b/tests/test_czech_locale.py index 103988e1..c55130d9 100644 --- a/tests/test_czech_locale.py +++ b/tests/test_czech_locale.py @@ -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: diff --git a/tests/test_issue6067_artifact_paths.py b/tests/test_issue6067_artifact_paths.py new file mode 100644 index 00000000..ab41c396 --- /dev/null +++ b/tests/test_issue6067_artifact_paths.py @@ -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//routes/quote&.py", "source": "&", "display": "über//routes/quote&.py", "name": "quote&.py", "head": "über//", "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("", "") + harness = f""" + + +
+
Artifacts proof fixture for issue #6067
+
+
+
Files
+
Artifacts
+
+
+
+
+ + + """ + 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", "&", "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 "" 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() diff --git a/tests/test_japanese_locale.py b/tests/test_japanese_locale.py index e6783451..811f31f1 100644 --- a/tests/test_japanese_locale.py +++ b/tests/test_japanese_locale.py @@ -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: diff --git a/tests/test_korean_locale.py b/tests/test_korean_locale.py index 3c7ced76..40d374bc 100644 --- a/tests/test_korean_locale.py +++ b/tests/test_korean_locale.py @@ -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: diff --git a/tests/test_polish_locale.py b/tests/test_polish_locale.py index 22abef9c..f3c583f8 100644 --- a/tests/test_polish_locale.py +++ b/tests/test_polish_locale.py @@ -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: diff --git a/tests/test_russian_locale.py b/tests/test_russian_locale.py index 0e751f0c..a060081c 100644 --- a/tests/test_russian_locale.py +++ b/tests/test_russian_locale.py @@ -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: diff --git a/tests/test_spanish_locale.py b/tests/test_spanish_locale.py index 39635c12..29d33ab3 100644 --- a/tests/test_spanish_locale.py +++ b/tests/test_spanish_locale.py @@ -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: diff --git a/tests/test_turkish_locale.py b/tests/test_turkish_locale.py index 5ece496b..a111e257 100644 --- a/tests/test_turkish_locale.py +++ b/tests/test_turkish_locale.py @@ -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: diff --git a/tests/test_vietnamese_locale.py b/tests/test_vietnamese_locale.py index 577cc667..1c86cba6 100644 --- a/tests/test_vietnamese_locale.py +++ b/tests/test_vietnamese_locale.py @@ -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: diff --git a/tests/test_zh_hant_locale.py b/tests/test_zh_hant_locale.py index bfa3a76b..29f53622 100644 --- a/tests/test_zh_hant_locale.py +++ b/tests/test_zh_hant_locale.py @@ -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: