Merge pull request #2306 into stage-360

Fix iPhone PWA mobile shell and workspace header layout — workspace panel polish + test cleanup (dobby-d-elf, follow-up to stage-359 #2306)
This commit is contained in:
Hermes Agent 2026-05-15 16:15:36 +00:00
commit 36d82f1aa1
8 changed files with 120 additions and 308 deletions

View file

@ -201,11 +201,8 @@ function syncWorkspacePanelUI(){
const clearBtn=$('btnClearPreview');
if(clearBtn){
clearBtn.disabled=!isOpen;
_setButtonTooltip(clearBtn, hasPreview?'Close preview':'Hide workspace panel');
// On desktop, only show the X button when a file preview is open.
// In browse mode the chevron (btnCollapseWorkspacePanel) already serves
// as the close control, so showing both produces a duplicate X.
if(!isCompact) clearBtn.style.display=hasPreview?'':'none';
_setButtonTooltip(clearBtn, hasPreview?'Close preview':'Close');
if(!isCompact) clearBtn.style.display='';
}
}

View file

@ -1181,17 +1181,15 @@
<aside class="rightpanel">
<div class="resize-handle" id="rightpanelResize"></div>
<div class="panel-header">
<span id="workspacePanelHeading" class="workspace-panel-heading" title="Workspace">Workspace</span><span id="workspaceHiddenIndicator" class="workspace-hidden-indicator" data-i18n-title="workspace_hidden_files_visible_title" title="Hidden files are visible — click for options" hidden onclick="toggleWorkspacePrefsMenu(event)"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg><span data-i18n="workspace_hidden_files_visible">hidden visible</span></span>
<div class="workspace-panel-title-group"><span id="workspacePanelHeading" class="workspace-panel-heading" title="Workspace">Workspace</span><span id="workspaceHiddenIndicator" class="workspace-hidden-indicator" data-i18n-title="workspace_hidden_files_visible_title" title="Hidden files are visible — click for options" hidden onclick="toggleWorkspacePrefsMenu(event)"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg><span data-i18n="workspace_hidden_files_visible">hidden visible</span></span></div>
<span class="git-badge" id="gitBadge" style="display:none"></span>
<div class="panel-actions">
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnCollapseWorkspacePanel" data-tooltip="Hide workspace panel" onclick="toggleWorkspacePanel(false)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg></button>
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnUpDir" data-tooltip="Parent directory" onclick="navigateUp()" style="display:none"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button>
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnNewFile" data-tooltip="New file" onclick="promptNewFile()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnNewFolder" data-tooltip="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnRefreshPanel" data-tooltip="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnWorkspacePrefs" data-tooltip="Workspace options" data-i18n-title="workspace_options" aria-haspopup="true" aria-expanded="false" onclick="toggleWorkspacePrefsMenu(event)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg><span class="workspace-prefs-dot" id="workspacePrefsDot" hidden></span></button>
<button class="panel-icon-btn close-preview has-tooltip has-tooltip--bottom" id="btnClearPreview" data-tooltip="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<button class="panel-icon-btn mobile-close-btn" onclick="handleWorkspaceClose()" title="Close" aria-label="Close workspace panel">×</button>
</div>
</div>
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>

View file

@ -1279,19 +1279,17 @@
panel's width so the header can collapse less-essential elements as the
user resizes the panel narrower. */
.rightpanel{width:300px;background:var(--sidebar);border-left:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;min-width:0;opacity:1;transform:translateX(0);transform-origin:right center;transition:width .24s cubic-bezier(.22,1,.36,1),opacity .18s ease,transform .24s cubic-bezier(.22,1,.36,1),border-color .24s ease;container-type:inline-size;container-name:rightpanel;}
/* Collapse priority as the panel narrows: git-badge first, then "Workspace"
label, never the icon buttons. flex-shrink ratios give graceful ellipsis;
@container queries below cut to display:none at hard breakpoints. */
.panel-header{padding:12px 16px;border-bottom:1px solid var(--border);font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;display:flex;align-items:center;gap:6px;overflow:visible;}
.panel-header > span:first-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;flex-shrink:2;}
.panel-header{padding:12px 16px;border-bottom:1px solid var(--border);font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:6px;overflow:visible;}
.workspace-panel-title-group{grid-column:1;grid-row:1;display:flex;align-items:center;min-width:0;}
.workspace-panel-title-group > span:first-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;}
.workspace-panel-heading{cursor:default;border-radius:6px;padding:2px 4px;margin:-2px -4px;}
.workspace-panel-heading.workspace-panel-heading--enabled{cursor:pointer;}
.workspace-panel-heading.workspace-panel-heading--enabled:hover,
.workspace-panel-heading.workspace-panel-heading--enabled:focus-visible{color:var(--text);background:var(--hover-bg);outline:none;}
.git-badge{font-size:9px;font-weight:600;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;letter-spacing:.02em;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;flex-shrink:3;overflow:hidden;min-width:0;}
.git-badge{grid-column:1 / -1;grid-row:2;justify-self:start;font-size:9px;font-weight:600;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;letter-spacing:.02em;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;overflow:hidden;min-width:0;max-width:100%;}
.topbar-source-badge{display:inline-flex;align-items:center;margin-left:6px;padding:2px 7px;border-radius:999px;background:var(--accent-bg);color:var(--accent-text);font-size:10px;font-weight:700;letter-spacing:.02em;vertical-align:middle;}
.git-badge.dirty{color:var(--accent-text);background:var(--accent-bg);}
.panel-actions{display:flex;gap:4px;flex-shrink:0;margin-left:auto;}
.panel-actions{grid-column:2;grid-row:1;display:flex;gap:4px;flex-shrink:0;margin-left:auto;}
/* Crisp display:none at narrow widths so the header doesn't show a sliver
of an ellipsised label or git badge — icons survive longest. */
@ -1299,9 +1297,8 @@
.git-badge{display:none !important;}
}
@container rightpanel (max-width: 160px){
.panel-header > span:first-child{display:none;}
.workspace-panel-title-group{display:none;}
}
.mobile-close-btn{display:none;}
.panel-icon-btn{width:24px;height:24px;background:none;border:none;color:var(--muted);cursor:pointer;border-radius:5px;font-size:13px;display:flex;align-items:center;justify-content:center;transition:all .15s;}
.panel-icon-btn:hover{background:var(--hover-bg);color:var(--text);}
.panel-icon-btn:disabled{opacity:.35;cursor:not-allowed;}
@ -1310,7 +1307,6 @@
/* file-item-actions removed: delete button is now a flex child */
.file-action-btn{width:20px;height:20px;background:rgba(0,0,0,.4);border:none;border-radius:4px;color:var(--muted);cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;}
.file-action-btn:hover{color:var(--accent);}
.close-preview{cursor:pointer;opacity:.6;}.close-preview:hover{opacity:1;}
/* Breadcrumb navigation */
.breadcrumb-bar{display:flex;align-items:center;gap:2px;padding:6px 12px;font-size:12px;border-bottom:1px solid var(--border);flex-shrink:0;overflow:hidden;white-space:nowrap;}
/* ── Workspace prefs kebab menu (#1793) ─────────────────────────── */
@ -1437,9 +1433,6 @@
@media(max-width:900px){
.rightpanel{display:none}
.workspace-toggle-btn,.mobile-files-btn{display:inline-flex!important;}
.mobile-close-btn{display:flex;}
.close-preview{display:none;}
#btnCollapseWorkspacePanel{display:none;}
}
@container composer-footer (max-width: 700px){
@ -1539,11 +1532,8 @@
box-shadow:none!important;}
.rightpanel.mobile-open{right:0!important;box-shadow:-4px 0 24px rgba(0,0,0,.4)!important;}
.rightpanel .resize-handle{display:none;}
.rightpanel .panel-header{display:grid;grid-template-columns:minmax(0,1fr) auto;row-gap:8px;}
.rightpanel .panel-header > span:first-child{grid-column:1;min-width:0;}
.rightpanel .git-badge{grid-column:2;justify-self:end;}
.rightpanel .panel-actions{grid-column:1 / -1;width:100%;align-items:center;gap:8px;justify-content:flex-start;margin-left:0;}
.rightpanel .mobile-close-btn{margin-left:auto;min-width:36px;min-height:36px;}
.rightpanel .panel-header{row-gap:8px;}
.rightpanel .panel-actions{align-items:center;gap:8px;}
/* Topbar adjustments */
.topbar{padding:8px 12px;gap:8px;}
.topbar-title{font-size:14px;}

View file

@ -1,12 +1,12 @@
"""
Tests for issue #781 — duplicate X close button in workspace preview header
on window resize below 900px breakpoint.
Tests for issue #781 — duplicate X close button in workspace preview header.
The fix: a single btnClearPreview (.close-preview) is the only close button,
visible on all devices. The mobile-close-btn element was removed entirely.
Verifies that:
- .close-preview is hidden (display:none) inside the @media (max-width:900px) block
- .mobile-close-btn is shown (display:flex) inside the same @media block
Both rules must appear inside the same @media(max-width:900px) block so that
at mobile widths only the mobile-close-btn is visible.
- .close-preview is NOT hidden by any media query (visible everywhere)
- .mobile-close-btn has no CSS rules remaining (element removed from HTML)
"""
import re
@ -44,88 +44,33 @@ def _extract_media_block(css, media_query_pattern):
raise AssertionError("Unmatched brace in CSS after @media block")
def _strip_media_blocks(css):
"""Remove all @media {...} blocks from CSS, returning base rules only."""
result = []
i = 0
while i < len(css):
# Look for @media keyword
m = re.search(r"@media\b", css[i:])
if not m:
result.append(css[i:])
break
# Append everything before this @media
result.append(css[i : i + m.start()])
# Find the opening brace of this @media block
brace_start = css.index("{", i + m.start())
depth = 0
j = brace_start
while j < len(css):
if css[j] == "{":
depth += 1
elif css[j] == "}":
depth -= 1
if depth == 0:
i = j + 1
break
j += 1
else:
break
return "".join(result)
_MEDIA_900_PATTERN = r"@media\s*\(\s*max-width\s*:\s*900px\s*\)"
def test_mobile_close_btn_displayed_in_900px_block():
"""mobile-close-btn must be display:flex inside the 900px media query."""
def test_close_preview_not_hidden_in_900px_block():
"""The single close button (.close-preview) must NOT be hidden in any media query."""
css = _load_css()
block = _extract_media_block(css, _MEDIA_900_PATTERN)
assert ".mobile-close-btn" in block, (
".mobile-close-btn rule is missing from @media(max-width:900px) block"
)
rule_match = re.search(r"\.mobile-close-btn\s*\{([^}]*)\}", block)
assert rule_match, ".mobile-close-btn rule body not found in 900px block"
assert "display:flex" in rule_match.group(1).replace(" ", ""), (
".mobile-close-btn should have display:flex in the 900px media query"
assert ".close-preview" not in block, (
".close-preview must not appear in @media(max-width:900px) block — "
"the single X button should be visible on all devices"
)
def test_close_preview_hidden_in_900px_block():
""".close-preview must be display:none inside the 900px media query (fix for #781)."""
def test_mobile_close_btn_not_in_css():
"""mobile-close-btn CSS rules should have been removed entirely."""
css = _load_css()
block = _extract_media_block(css, _MEDIA_900_PATTERN)
assert ".close-preview" in block, (
".close-preview rule is missing from @media(max-width:900px) block — "
"the duplicate-button fix (#781) may have been reverted"
)
rule_match = re.search(r"\.close-preview\s*\{([^}]*)\}", block)
assert rule_match, ".close-preview rule body not found in 900px block"
assert "display:none" in rule_match.group(1).replace(" ", ""), (
".close-preview should have display:none in the 900px media query to hide "
"the duplicate desktop X button at mobile widths"
assert ".mobile-close-btn" not in css, (
".mobile-close-btn CSS rule still present — the element was removed "
"from HTML so its styles should be cleaned up too"
)
def test_both_rules_in_same_media_block():
"""Both .close-preview and .mobile-close-btn must appear in the same 900px block."""
def test_close_preview_visible_in_base_css():
"""Outside media queries, .close-preview must NOT be display:none."""
css = _load_css()
block = _extract_media_block(css, _MEDIA_900_PATTERN)
assert ".mobile-close-btn" in block, (
".mobile-close-btn missing from @media(max-width:900px) block"
)
assert ".close-preview" in block, (
".close-preview missing from @media(max-width:900px) block"
)
def test_close_preview_visible_outside_media_query():
"""Outside the media query, .close-preview must NOT be display:none
(it should remain visible on desktop)."""
css = _load_css()
base_css = _strip_media_blocks(css)
close_rules = re.findall(r"\.close-preview\s*\{([^}]*)\}", base_css)
for rule_body in close_rules:
assert "display:none" not in rule_body.replace(" ", ""), (
".close-preview must not be hidden in base (desktop) CSS"
# Simple check: find all .close-preview rules and ensure none set display:none
for m in re.finditer(r"\.close-preview\s*\{([^}]*)\}", css):
assert "display:none" not in m.group(1).replace(" ", ""), (
".close-preview must not be hidden by any CSS rule"
)

View file

@ -205,16 +205,8 @@ def test_rightpanel_mobile_slide_over_css():
assert re.search(r'\.rightpanel\.mobile-open\{[^}]*box-shadow:\s*-4px 0 24px rgba\(0,\s*0,\s*0,\s*\.?4\)',
rightpanel_block, re.DOTALL), \
"open mobile rightpanel should keep the edge shadow"
assert re.search(r'\.rightpanel\s+\.panel-header\{[^}]*display:\s*grid[^}]*grid-template-columns:\s*minmax\(0,1fr\)\s+auto', rightpanel_block), \
"mobile workspace header should use a compact two-row grid"
assert re.search(r'\.rightpanel\s+\.panel-header\s*>\s*span:first-child\{[^}]*grid-column:\s*1', rightpanel_block), \
"mobile workspace heading should stay on the first row"
assert re.search(r'\.rightpanel\s+\.git-badge\{[^}]*grid-column:\s*2[^}]*justify-self:\s*end', rightpanel_block), \
"mobile git badge should share the first row with the heading"
assert re.search(r'\.rightpanel\s+\.panel-actions\{[^}]*grid-column:\s*1 / -1[^}]*width:\s*100%', rightpanel_block), \
"mobile workspace actions should span the full second row"
assert re.search(r'\.rightpanel\s+\.mobile-close-btn\{[^}]*margin-left:\s*auto', rightpanel_block), \
"mobile workspace close button should align to the far right"
assert re.search(r'\.rightpanel\s+\.panel-header\{[^}]*row-gap:\s*8px', rightpanel_block), \
"mobile workspace header should keep comfortable row spacing"
def test_workspace_panel_inline_width_is_desktop_only():

View file

@ -1,15 +1,11 @@
"""
Sprint 41 Tests: Title auto-generation fix + mobile close button CSS (PR #333).
Sprint 41 Tests: Title auto-generation fix (PR #333).
Covers:
- streaming.py: sessions titled 'New Chat' trigger auto-title generation
- streaming.py: sessions with empty/falsy title trigger auto-title generation
- streaming.py: sessions titled 'Untitled' (original guard) still trigger
- streaming.py: sessions with a user-set title do NOT trigger auto-title
- style.css: .mobile-close-btn is hidden by default (desktop rule present)
- style.css: .mobile-close-btn shown in <=900px media query
- style.css: #btnCollapseWorkspacePanel hidden in <=900px media query
- index.html: both .mobile-close-btn and #btnCollapseWorkspacePanel buttons exist
"""
import pathlib
import re
@ -59,71 +55,6 @@ class TestTitleAutoGenerationCondition(unittest.TestCase):
"Expected at least 3 OR-joined sub-conditions (Untitled, New Chat, not s.title)")
# ── style.css: mobile close button visibility ─────────────────────────────
class TestMobileCloseButtonCSS(unittest.TestCase):
"""Verify CSS rules that control the duplicate close button on mobile."""
def test_mobile_close_btn_hidden_by_default(self):
"""Desktop default: .mobile-close-btn must be display:none outside any media query."""
# Find the rule before the first @media block that contains mobile-close-btn
# We look for the pattern in the desktop (non-media-query) section
self.assertIn(
".mobile-close-btn{display:none;}",
CSS.replace(" ", ""),
".mobile-close-btn should be hidden by default (desktop) — rule missing or wrong"
)
def test_mobile_close_btn_shown_in_900px_query(self):
"""Inside max-width:900px media query, .mobile-close-btn must be display:flex."""
# Extract the 900px media block
m = re.search(r'@media\s*\(max-width\s*:\s*900px\)\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}',
CSS)
self.assertIsNotNone(m, "@media(max-width:900px) block not found in style.css")
block = m.group(1).replace(" ", "")
self.assertIn(".mobile-close-btn{display:flex;}",
block,
".mobile-close-btn must be display:flex inside the 900px media query")
def test_desktop_collapse_btn_hidden_in_900px_query(self):
"""Inside max-width:900px media query, #btnCollapseWorkspacePanel must be display:none."""
m = re.search(r'@media\s*\(max-width\s*:\s*900px\)\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}',
CSS)
self.assertIsNotNone(m, "@media(max-width:900px) block not found in style.css")
block = m.group(1).replace(" ", "")
self.assertIn("#btnCollapseWorkspacePanel{display:none;}",
block,
"#btnCollapseWorkspacePanel must be display:none in 900px media query")
def test_900px_query_retains_existing_rules(self):
"""Ensure the PR didn't accidentally drop existing rules from the 900px block."""
m = re.search(r'@media\s*\(max-width\s*:\s*900px\)\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}',
CSS)
self.assertIsNotNone(m)
block = m.group(1)
self.assertIn("rightpanel", block, ".rightpanel rule missing from 900px block")
self.assertIn("mobile-files-btn", block, ".mobile-files-btn rule missing from 900px block")
# ── index.html: button presence ───────────────────────────────────────────
class TestWorkspacePanelButtons(unittest.TestCase):
"""Verify both panel buttons are present in the HTML so CSS rules have targets."""
def test_desktop_collapse_button_exists(self):
self.assertIn("btnCollapseWorkspacePanel", HTML,
"#btnCollapseWorkspacePanel button must exist in index.html")
def test_mobile_close_button_exists(self):
self.assertIn("mobile-close-btn", HTML,
".mobile-close-btn button must exist in index.html")
def test_mobile_close_button_has_aria_label(self):
"""Accessibility: mobile close button must have an aria-label."""
m = re.search(r'class="[^"]*mobile-close-btn[^"]*"[^>]*>', HTML)
self.assertIsNotNone(m, "Could not find mobile-close-btn element")
self.assertIn("aria-label", m.group(0),
"mobile-close-btn must have aria-label for accessibility")
class TestIssue495TitleStreaming(unittest.TestCase):

View file

@ -1,15 +1,13 @@
"""
Sprint 44 Tests: Workspace panel close button fixes (PR #413).
Sprint 44 Tests: Workspace panel close button (PR #413).
Covers:
- index.html: mobile-close-btn now calls handleWorkspaceClose() instead of
closeWorkspacePanel(), so hitting X while a file is open returns you to the
file browser rather than collapsing the whole panel.
- boot.js: syncWorkspacePanelUI() hides #btnClearPreview (the X icon) on
desktop when no file preview is open, eliminating the duplicate X that
appeared alongside the chevron collapse button.
- boot.js: handleWorkspaceClose() logic — clears preview when one is visible,
closes panel otherwise (existing function, confirmed wired to both buttons).
- btnClearPreview is the single close button for all devices. Clicking it calls
handleWorkspaceClose(), which clears a file preview if open, or closes the
entire workspace panel otherwise.
- The mobile-close-btn element was removed — only one X button remains.
- Tooltip text updated to "Close" (when no preview) and "Close preview"
(when a file is being viewed).
"""
import pathlib
import re
@ -20,113 +18,79 @@ HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
class TestMobileCloseButtonBehavior(unittest.TestCase):
"""mobile-close-btn must call handleWorkspaceClose(), not closeWorkspacePanel()."""
class TestSingleCloseButton(unittest.TestCase):
"""btnClearPreview is the only close button, visible on all devices."""
def test_mobile_close_btn_calls_handle_workspace_close(self):
"""mobile-close-btn onclick must be handleWorkspaceClose(), not closeWorkspacePanel()."""
m = re.search(r'class="[^"]*mobile-close-btn[^"]*"[^>]*>', HTML)
self.assertIsNotNone(m, "mobile-close-btn element not found in index.html")
btn_html = m.group(0)
self.assertIn(
'onclick="handleWorkspaceClose()"',
btn_html,
"mobile-close-btn must call handleWorkspaceClose() so that hitting X "
"while a file is open closes the file first, not the whole panel",
)
def test_mobile_close_btn_does_not_call_close_workspace_panel_directly(self):
"""mobile-close-btn must NOT call closeWorkspacePanel() directly."""
m = re.search(r'class="[^"]*mobile-close-btn[^"]*"[^>]*>', HTML)
self.assertIsNotNone(m, "mobile-close-btn element not found in index.html")
btn_html = m.group(0)
self.assertNotIn(
'onclick="closeWorkspacePanel()"',
btn_html,
"mobile-close-btn must not call closeWorkspacePanel() directly — "
"it would bypass the two-step close logic and collapse the panel even "
"when a file is being viewed",
)
def test_handle_workspace_close_defined_in_boot_js(self):
"""handleWorkspaceClose() must be defined in boot.js."""
self.assertIn(
"function handleWorkspaceClose()",
BOOT_JS,
"handleWorkspaceClose() is missing from boot.js",
)
def test_handle_workspace_close_clears_preview_first(self):
"""handleWorkspaceClose() must call clearPreview() when a preview is visible."""
# The function must check for visible preview and call clearPreview
self.assertIn(
"clearPreview()",
BOOT_JS,
"handleWorkspaceClose() must call clearPreview() when preview is visible",
)
def test_handle_workspace_close_falls_back_to_close_panel(self):
"""handleWorkspaceClose() must call closeWorkspacePanel() as fallback."""
# Find the function start and extract until the closing brace by scanning
start = BOOT_JS.find("function handleWorkspaceClose()")
self.assertNotEqual(start, -1, "handleWorkspaceClose() not found in boot.js")
# Extract a generous window after the function start
fn_window = BOOT_JS[start : start + 400]
self.assertIn(
"closeWorkspacePanel()",
fn_window,
"handleWorkspaceClose() must call closeWorkspacePanel() as its fallback path",
)
class TestDesktopNoDuplicateXButton(unittest.TestCase):
"""On desktop, only one X/close control should appear at a time."""
def test_sync_workspace_panel_ui_hides_clear_preview_on_desktop(self):
"""syncWorkspacePanelUI() must set display:none on btnClearPreview when no preview and desktop."""
self.assertIn(
"clearBtn.style.display",
BOOT_JS,
"syncWorkspacePanelUI() must control clearBtn.style.display to hide it "
"on desktop when no file preview is open",
)
def test_clear_preview_hidden_when_no_preview(self):
"""The display toggle for btnClearPreview must key off hasPreview."""
# Expect something like: clearBtn.style.display=hasPreview?'':'none'
# or clearBtn.style.display = hasPreview ? '' : 'none'
pattern = r"clearBtn\.style\.display\s*=\s*hasPreview"
self.assertRegex(
BOOT_JS,
pattern,
"btnClearPreview display must be conditioned on hasPreview in "
"syncWorkspacePanelUI() to avoid a duplicate X on desktop",
)
def test_clear_preview_toggle_only_applied_on_desktop(self):
"""The display toggle must be guarded by !isCompact so mobile is unaffected."""
# Expect: if(!isCompact) clearBtn.style.display=...
pattern = r"isCompact.*clearBtn\.style\.display|clearBtn\.style\.display.*isCompact"
self.assertRegex(
BOOT_JS,
pattern,
"btnClearPreview display toggle must be guarded by isCompact so the "
"mobile X button visibility is not accidentally affected",
)
def test_btnclearpreview_exists_in_html(self):
"""#btnClearPreview must still exist in the HTML (not removed)."""
def test_btn_clear_preview_exists_in_html(self):
"""#btnClearPreview must exist in index.html."""
self.assertIn(
'id="btnClearPreview"',
HTML,
"#btnClearPreview must remain in index.html",
"#btnClearPreview must be present in index.html",
)
def test_btncollapseWorkspacepanel_exists_in_html(self):
"""#btnCollapseWorkspacePanel (chevron) must still exist in the HTML."""
self.assertIn(
'id="btnCollapseWorkspacePanel"',
def test_mobile_close_btn_removed_from_html(self):
"""mobile-close-btn element should no longer exist in index.html."""
self.assertNotIn(
"mobile-close-btn",
HTML,
"#btnCollapseWorkspacePanel must remain in index.html",
"mobile-close-btn was removed — only btnClearPreview remains as close control",
)
def test_btn_clear_preview_wired_to_handle_workspace_close(self):
"""btnClearPreview onclick must be handleWorkspaceClose."""
self.assertRegex(
BOOT_JS,
r"btnClearPreview.*onclick\s*=\s*handleWorkspaceClose",
"btnClearPreview must call handleWorkspaceClose so that clicking X "
"either clears a preview or closes the panel",
)
class TestHandleWorkspaceCloseLogic(unittest.TestCase):
"""handleWorkspaceClose() clears preview first, falls back to close panel."""
def test_function_defined(self):
self.assertIn(
"function handleWorkspaceClose()",
BOOT_JS,
"handleWorkspaceClose() must exist in boot.js",
)
def test_clears_preview_when_open(self):
idx = BOOT_JS.find("function handleWorkspaceClose()")
self.assertGreater(idx, 0, "handleWorkspaceClose() not found")
body = BOOT_JS[idx:idx + 500]
self.assertIn(
"clearPreview()",
body,
"handleWorkspaceClose() must call clearPreview() when a preview is open",
)
def test_falls_back_to_close_panel(self):
idx = BOOT_JS.find("function handleWorkspaceClose()")
self.assertGreater(idx, 0, "handleWorkspaceClose() not found")
body = BOOT_JS[idx:idx + 500]
self.assertIn(
"closeWorkspacePanel()",
body,
"handleWorkspaceClose() must call closeWorkspacePanel() as fallback",
)
class TestTooltipText(unittest.TestCase):
"""The X button tooltip says 'Close' when no preview, 'Close preview' otherwise."""
def test_tooltip_uses_close(self):
"""syncWorkspacePanelUI() sets tooltip to 'Close' (not 'Hide workspace panel')."""
idx = BOOT_JS.find("function syncWorkspacePanelUI()")
self.assertGreater(idx, 0, "syncWorkspacePanelUI() not found")
body = BOOT_JS[idx:idx + 2000]
# The tooltip line should contain 'Close' and NOT 'Hide workspace panel'
self.assertIn(
"'Close'",
body,
"btnClearPreview tooltip must use 'Close' instead of 'Hide workspace panel'",
)

View file

@ -74,20 +74,20 @@ class TestWorkspacePanelCollapsePriority:
# in #1775 so its tooltip pseudo-elements can escape the header bar
# (otherwise the workspace-panel header tooltips like "New file" get
# clipped). The title-text ellipsis is preserved by the inner span
# `.panel-header > span:first-child` which has its own
# `.workspace-panel-title-group > span:first-child` which has its own
# overflow:hidden + text-overflow:ellipsis. So we check that EITHER
# the parent uses overflow:hidden (legacy) or that the inner span
# handles its own ellipsis (current).
if "overflow:hidden" not in rule:
inner_span_idx = STYLE_CSS.find(".panel-header > span:first-child{")
inner_span_idx = STYLE_CSS.find(".workspace-panel-title-group > span:first-child{")
assert inner_span_idx != -1, (
".panel-header lost overflow:hidden but no inner span "
"rule (.panel-header > span:first-child) handles the "
"rule (.workspace-panel-title-group > span:first-child) handles the "
"title-text ellipsis as a fallback."
)
inner_rule = STYLE_CSS[inner_span_idx: STYLE_CSS.find("}", inner_span_idx) + 1]
assert "overflow:hidden" in inner_rule and "text-overflow:ellipsis" in inner_rule, (
".panel-header > span:first-child must own the ellipsis "
".workspace-panel-title-group > span:first-child must own the ellipsis "
"behaviour now that the parent is overflow:visible."
)
@ -106,26 +106,21 @@ class TestWorkspacePanelCollapsePriority:
)
def test_workspace_label_shrinks_with_ellipsis(self):
"""The "Workspace" label (`panel-header > span:first-child`) must
shrink with ellipsis truncation rather than overflow uncontrollably."""
"""The "Workspace" label must shrink with ellipsis truncation."""
# Find the rule
sel = ".panel-header > span:first-child"
sel = ".workspace-panel-title-group > span:first-child"
idx = STYLE_CSS.find(sel)
assert idx >= 0, f"Selector {sel!r} not found in style.css"
rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)]
assert "text-overflow:ellipsis" in rule
assert "min-width:0" in rule
assert "flex-shrink:2" in rule # shrinks before icons (icons are 0)
def test_git_badge_shrinks_first(self):
"""`.git-badge` must shrink faster than the label so it disappears
first as the panel narrows."""
def test_git_badge_uses_second_row(self):
"""`.git-badge` should sit beneath the title/action row."""
idx = STYLE_CSS.find(".git-badge{")
rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)]
assert "flex-shrink:3" in rule, (
".git-badge must have flex-shrink:3 so it shrinks before the "
"label (flex-shrink:2) and the icons (flex-shrink:0)."
)
assert "grid-column:1 / -1" in rule
assert "grid-row:2" in rule
def test_container_query_hides_git_badge_first(self):
"""At narrow widths the git badge gets `display:none` BEFORE the
@ -145,7 +140,7 @@ class TestWorkspacePanelCollapsePriority:
assert "@container rightpanel (max-width: 160px)" in STYLE_CSS
idx = STYLE_CSS.find("@container rightpanel (max-width: 160px)")
block = STYLE_CSS[idx: idx + 200]
assert ".panel-header > span:first-child{display:none" in block
assert ".workspace-panel-title-group{display:none" in block
def test_breakpoints_in_correct_order(self):
"""Sanity: the git-badge breakpoint (220px) must be wider than the