diff --git a/static/panels.js b/static/panels.js index 6e16b417..cb42c709 100644 --- a/static/panels.js +++ b/static/panels.js @@ -4614,7 +4614,14 @@ function _setMemoryHeaderButtons(mode) { const cancelBtn = $('btnCancelMemoryDetail'); const saveBtn = $('btnSaveMemoryDetail'); const meta = _memorySectionMeta(_currentMemorySection); - if (mode === 'read' && _currentMemorySection !== 'external_notes' && !meta.readOnly) { if (header) header.style.display = 'flex'; show(editBtn); hide(cancelBtn); hide(saveBtn); } + if (mode === 'read') { + // Any read view has a populated title → header must be visible. Only the + // Edit affordance is gated on the section being editable (read-only + // sections like Project Context / External Notes still show the header). + if (header) header.style.display = 'flex'; + if (_currentMemorySection !== 'external_notes' && !meta.readOnly) show(editBtn); else hide(editBtn); + hide(cancelBtn); hide(saveBtn); + } else if (mode === 'edit') { if (header) header.style.display = 'flex'; hide(editBtn); show(cancelBtn); show(saveBtn); } else { if (header) header.style.display = 'none'; hide(editBtn); hide(cancelBtn); hide(saveBtn); } } @@ -5788,7 +5795,7 @@ function _renderProfileConceptHelp(activeName){ if (empty) empty.style.display = 'none'; _profileMode = 'read'; _currentProfileDetail = null; - _setProfileHeaderButtons('empty'); + _setProfileHeaderButtons('help'); } function _renderProfileDetail(p, activeName){ @@ -5848,6 +5855,11 @@ function _setProfileHeaderButtons(mode, p, activeName){ } else if (mode === 'create') { if (header) header.style.display = 'flex'; hide(actBtn); hide(delBtn); show(cancelBtn); show(saveBtn); + } else if (mode === 'help') { + // Read-only help/concept view: title is populated, so show the header but + // hide every action button (no profile to act on). + if (header) header.style.display = 'flex'; + [actBtn, delBtn, cancelBtn, saveBtn].forEach(hide); } else { if (header) header.style.display = 'none'; [actBtn, delBtn, cancelBtn, saveBtn].forEach(hide); diff --git a/tests/test_issue4891_detail_header_visibility.py b/tests/test_issue4891_detail_header_visibility.py new file mode 100644 index 00000000..7b65fcfa --- /dev/null +++ b/tests/test_issue4891_detail_header_visibility.py @@ -0,0 +1,92 @@ +"""Regression guard for #4891: detail-panel headers must stay visible in read +modes that have a populated title. + +#4891 fixed task-detail action buttons being invisible on mobile PWA (the +`:has(.main-view-title:empty)` pseudo-class didn't re-evaluate after dynamic +textContent on some WebViews) by explicitly toggling `header.style.display` in +the per-panel header-button setters. The first cut of that fix introduced two +SILENT regressions (caught by the gate): read-only Memory sections (Project +Context / External Notes) and the Profiles "Profiles vs workspaces" help view +both have a populated title but fell into an `else`/`empty` branch that set the +header to `display:none`, hiding their header+title. + +This guards the invariant: any setter branch that corresponds to a populated +read/help view must show the header (flex), never hide it. +""" +from __future__ import annotations + +import re +from pathlib import Path + +PANELS = (Path(__file__).resolve().parent.parent / "static" / "panels.js").read_text(encoding="utf-8") + + +def _fn_body(name: str) -> str: + i = PANELS.find(f"function {name}(") + assert i != -1, f"{name} not found in panels.js" + # crude brace matcher from the first '{' after the signature + start = PANELS.index("{", i) + depth = 0 + for j in range(start, len(PANELS)): + if PANELS[j] == "{": + depth += 1 + elif PANELS[j] == "}": + depth -= 1 + if depth == 0: + return PANELS[start : j + 1] + raise AssertionError(f"unbalanced braces in {name}") + + +def test_memory_read_mode_shows_header_even_for_readonly_sections(): + """_setMemoryHeaderButtons('read') must show the header for ALL read views + (incl. read-only Project Context / External Notes), only gating the Edit + button on editability — not hide the whole header (#4891).""" + body = _fn_body("_setMemoryHeaderButtons") + # The 'read' branch must set the header to flex unconditionally (the title + # is populated in every read view). + m = re.search(r"if\s*\(\s*mode\s*===\s*'read'\s*\)\s*\{", body) + assert m, "_setMemoryHeaderButtons must have a dedicated mode==='read' branch" + read_branch = body[m.end(): m.end() + 400] + assert "header.style.display = 'flex'" in read_branch, ( + "memory 'read' branch must show the header (flex) so read-only sections " + "keep their header/title (#4891 regression)" + ) + # The read-only gating must apply to the Edit button, not the whole header. + assert "external_notes" in read_branch and "readOnly" in read_branch, ( + "memory 'read' branch must gate only the Edit button on read-only/" + "external_notes, not the header visibility (#4891)" + ) + + +def test_profile_help_view_keeps_header_visible(): + """The 'Profiles vs workspaces' help view sets a title, so it must use a + header mode that shows the header — not the 'empty' mode that hides it.""" + help_body = _fn_body("_renderProfileConceptHelp") + assert "_setProfileHeaderButtons('empty')" not in help_body, ( + "profile help view must NOT call _setProfileHeaderButtons('empty') — " + "that hides the populated help title (#4891 regression)" + ) + assert "_setProfileHeaderButtons('help')" in help_body, ( + "profile help view must use the 'help' header mode (shows header, hides " + "action buttons) (#4891)" + ) + setter = _fn_body("_setProfileHeaderButtons") + m = re.search(r"mode\s*===\s*'help'\s*\)\s*\{", setter) + assert m, "_setProfileHeaderButtons must handle a 'help' mode" + help_branch = setter[m.end(): m.end() + 240] + assert "header.style.display = 'flex'" in help_branch, ( + "'help' header mode must show the header (flex) (#4891)" + ) + + +def test_task_detail_header_shown_in_read_and_edit(): + """The original #4891 fix: task-detail header is explicitly shown in read/ + create/edit and hidden only in the closed/empty state.""" + body = _fn_body("_setCronHeaderButtons") + assert body.count("header.style.display = 'flex'") >= 2, ( + "_setCronHeaderButtons must explicitly show the header in read and " + "create/edit modes (#4891)" + ) + assert "header.style.display = 'none'" in body, ( + "_setCronHeaderButtons must hide the header in the closed/empty state (#4891)" + )