Release v0.51.288 — Release JD (stage-r24 — collapsible approval card #3515) (#3697)
Some checks failed
Release & Docker / release (push) Has been cancelled
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat(approval): make the approval card collapsible (#3515) Adds a collapse toggle to the approval card header so users can shrink it to a thin header strip and keep the tool-call rationale/transcript above readable. Full ARIA (aria-expanded/controls/label), chevron swap, and transcript reflow that preserves near-bottom scroll. Closes #3007. Co-authored-by: Rod Boev <rod.boev@gmail.com> * docs(changelog): v0.51.288 — Release JD (stage-r24) * fix(approval): clear collapsed state for a distinct queued approval (#3515) Codex regression-gate finding: showApprovalCard's sameApproval check didn't include approval_id and didn't clear .collapsed in the !sameApproval branch, so a NEW/parallel approval arriving while the card was already collapsed could render collapsed with its command + action buttons hidden. Add approval_id to the signature; clear .collapsed for a distinct approval before syncing. +2 regression tests. --------- Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: nesquena-hermes <[email protected]>
This commit is contained in:
@@ -3,6 +3,11 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.288] — 2026-06-06 — Release JD (stage-r24 — collapsible approval card)
|
||||
|
||||
### Added
|
||||
- **The tool-call approval card can be collapsed to a thin header strip.** A chevron toggle in the approval-card header shrinks the card to just its "Approval required" heading so the tool-call rationale and transcript scrolled above it stay readable; clicking again re-expands it. Includes full ARIA (`aria-expanded`/`aria-controls`/`aria-label`), an icon swap, and transcript reflow that preserves a near-bottom scroll position. State resets to expanded for each new approval, so a fresh approval is never hidden. (#3515, @rodboev; closes #3007)
|
||||
|
||||
## [v0.51.287] — 2026-06-06 — Release JC (stage-r22 — WeCom session classification + worker-profile picker hiding)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -456,11 +456,12 @@
|
||||
<div class="approval-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
<span id="approvalHeading" data-i18n="approval_heading">Approval required</span>
|
||||
<button type="button" class="approval-collapse" id="approvalCollapse" aria-expanded="true" aria-label="Collapse approval" aria-controls="approvalDesc approvalCmd approvalCounter approvalBtns" onclick="toggleApprovalCardCollapsed()" title="Collapse approval"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
|
||||
</div>
|
||||
<div class="approval-desc" id="approvalDesc"></div>
|
||||
<div class="approval-cmd" id="approvalCmd"></div>
|
||||
<div class="approval-counter" id="approvalCounter" style="display:none;font-size:0.75em;opacity:0.6;margin-top:4px;"></div>
|
||||
<div class="approval-btns">
|
||||
<div class="approval-btns" id="approvalBtns">
|
||||
<button class="approval-btn once" id="approvalBtnOnce" onclick="respondApproval('once')" title="Allow this one command (Enter)" data-i18n-title="approval_btn_once_title">
|
||||
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
|
||||
<span class="approval-btn-label" data-i18n="approval_btn_once">Allow once</span>
|
||||
|
||||
@@ -2928,6 +2928,8 @@ function hideApprovalCard(force=false) {
|
||||
_approvalSessionId = null;
|
||||
_resetApprovalCardState();
|
||||
card.classList.remove("visible");
|
||||
card.classList.remove("collapsed");
|
||||
_syncApprovalTranscriptSpace(null);
|
||||
$("approvalCmd").textContent = "";
|
||||
$("approvalDesc").textContent = "";
|
||||
}
|
||||
@@ -2982,7 +2984,7 @@ function showApprovalCard(pending, pendingCount) {
|
||||
const keys = pending.pattern_keys || (pending.pattern_key ? [pending.pattern_key] : []);
|
||||
const desc = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : "");
|
||||
const cmd = pending.command || "";
|
||||
const sig = JSON.stringify({desc, cmd, sid: pending._session_id || (S.session && S.session.session_id) || null});
|
||||
const sig = JSON.stringify({desc, cmd, sid: pending._session_id || (S.session && S.session.session_id) || null, approval_id: pending.approval_id || null});
|
||||
const card = $("approvalCard");
|
||||
const sameApproval = card.classList.contains("visible") && _approvalSignature === sig;
|
||||
$("approvalDesc").textContent = desc;
|
||||
@@ -3003,12 +3005,17 @@ function showApprovalCard(pending, pendingCount) {
|
||||
if (!sameApproval) {
|
||||
_approvalVisibleSince = Date.now();
|
||||
_clearApprovalHideTimer();
|
||||
// A distinct approval must always render expanded — never inherit a prior
|
||||
// approval's collapsed state, which would hide its command + action buttons. (#3515)
|
||||
card.classList.remove("collapsed");
|
||||
}
|
||||
// Re-enable buttons in case a previous approval disabled them
|
||||
["approvalBtnOnce","approvalBtnSession","approvalBtnAlways","approvalBtnDeny"].forEach(id => {
|
||||
const b = $(id); if (b) { b.disabled = false; b.classList.remove("loading"); }
|
||||
});
|
||||
card.classList.add("visible");
|
||||
_syncApprovalCollapseButton(card);
|
||||
_syncApprovalTranscriptSpace(card, {immediate: true});
|
||||
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
|
||||
const onceBtn = $("approvalBtnOnce");
|
||||
if (onceBtn && document.activeElement !== $('msg')) {
|
||||
@@ -3016,6 +3023,65 @@ function showApprovalCard(pending, pendingCount) {
|
||||
}
|
||||
}
|
||||
|
||||
function _syncApprovalCollapseButton(card) {
|
||||
const collapse = $("approvalCollapse");
|
||||
if (!collapse || !card) return;
|
||||
const collapsed = card.classList.contains("collapsed");
|
||||
collapse.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||
// Icon swap: chevron-down when expanded (click to collapse), chevron-up when collapsed (click to expand)
|
||||
const polyline = collapse.querySelector("svg polyline");
|
||||
if (polyline) polyline.setAttribute("points", collapsed ? "18 15 12 9 6 15" : "6 9 12 15 18 9");
|
||||
const label = collapsed ? "Expand approval" : "Collapse approval";
|
||||
collapse.setAttribute("aria-label", label);
|
||||
collapse.title = label;
|
||||
}
|
||||
|
||||
function _approvalMessagesNearBottom(messages) {
|
||||
if (!messages) return false;
|
||||
return messages.scrollHeight - messages.scrollTop - messages.clientHeight < 150;
|
||||
}
|
||||
|
||||
function _syncApprovalTranscriptSpace(card, opts) {
|
||||
opts = opts || {};
|
||||
const messages = $("messages");
|
||||
if (!messages) return;
|
||||
const wasNearBottom = _approvalMessagesNearBottom(messages);
|
||||
if (!card || !card.classList.contains("visible")) {
|
||||
messages.classList.remove("approval-open");
|
||||
messages.classList.remove("approval-collapsed");
|
||||
messages.style.removeProperty("--approval-card-height");
|
||||
messages.style.removeProperty("--approval-dock-height");
|
||||
if (wasNearBottom && typeof scrollToBottom === "function" && typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(scrollToBottom);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const collapsed = card.classList.contains("collapsed");
|
||||
messages.classList.add("approval-open");
|
||||
messages.classList.toggle("approval-collapsed", collapsed);
|
||||
const measure = () => {
|
||||
if (!card.classList.contains("visible")) return;
|
||||
const target = collapsed ? card : (card.querySelector(".approval-inner") || card);
|
||||
const h = target && target.getBoundingClientRect().height;
|
||||
if (h > 0) {
|
||||
messages.style.setProperty(collapsed ? "--approval-dock-height" : "--approval-card-height", Math.ceil(h + 24) + "px");
|
||||
}
|
||||
if (wasNearBottom && typeof scrollToBottom === "function") scrollToBottom();
|
||||
};
|
||||
if (opts.immediate) measure();
|
||||
if (typeof requestAnimationFrame === "function") requestAnimationFrame(measure);
|
||||
setTimeout(measure, 420);
|
||||
}
|
||||
|
||||
function toggleApprovalCardCollapsed(forceCollapsed) {
|
||||
const card = $("approvalCard");
|
||||
if (!card) return;
|
||||
const collapsed = typeof forceCollapsed === "boolean" ? forceCollapsed : !card.classList.contains("collapsed");
|
||||
card.classList.toggle("collapsed", collapsed);
|
||||
_syncApprovalCollapseButton(card);
|
||||
_syncApprovalTranscriptSpace(card, {immediate: true});
|
||||
}
|
||||
|
||||
async function respondApproval(choice) {
|
||||
const sid = _approvalSessionId || (S.session && S.session.session_id);
|
||||
if (!sid) return;
|
||||
|
||||
@@ -1395,6 +1395,14 @@
|
||||
.approval-inner{background:var(--surface);backdrop-filter:blur(8px);border:1px solid var(--accent-bg-strong);border-radius:14px;padding:16px 18px 40px;transform:translateY(100%);opacity:0;transition:transform .4s cubic-bezier(.32,.72,.16,1),opacity .25s ease;}
|
||||
.approval-card.visible .approval-inner{transform:translateY(0);opacity:1;}
|
||||
.approval-header{display:flex;align-items:center;gap:8px;margin-bottom:10px;font-size:13px;font-weight:600;color:var(--error);}
|
||||
.approval-collapse{margin-left:auto;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border:1px solid var(--border2);border-radius:999px;background:var(--surface);color:var(--muted);font:inherit;padding:0;cursor:pointer;}
|
||||
.approval-collapse:hover{color:var(--text);border-color:var(--accent-bg-strong);}
|
||||
.approval-collapse svg{width:14px;height:14px;display:block;}
|
||||
.approval-collapse:focus-visible{outline:2px solid var(--blue);outline-offset:2px;}
|
||||
.approval-card.collapsed{max-height:56px;bottom:8px;}
|
||||
.approval-card.collapsed .approval-inner{max-height:48px;overflow:hidden;padding:10px 14px;}
|
||||
.approval-card.collapsed .approval-header{margin-bottom:0;}
|
||||
.approval-card.collapsed .approval-desc,.approval-card.collapsed .approval-cmd,.approval-card.collapsed .approval-counter,.approval-card.collapsed .approval-btns{display:none;}
|
||||
.approval-desc{font-size:12px;color:var(--muted);margin-bottom:8px;line-height:1.5;}
|
||||
.approval-cmd{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:var(--pre-text);white-space:pre-wrap;word-break:break-all;margin-bottom:14px;max-height:120px;overflow-y:auto;}
|
||||
.approval-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
|
||||
@@ -1422,6 +1430,8 @@
|
||||
.messages.handoff-dock-visible{padding-bottom:var(--handoff-dock-height,72px);scroll-padding-bottom:var(--handoff-dock-height,72px);transition:padding-bottom .22s cubic-bezier(.2,.8,.2,1);}
|
||||
.messages.clarify-open{padding-bottom:var(--clarify-card-height,320px);scroll-padding-bottom:var(--clarify-card-height,320px);transition:padding-bottom .22s cubic-bezier(.2,.8,.2,1);}
|
||||
.messages.clarify-collapsed{padding-bottom:var(--clarify-dock-height,72px);scroll-padding-bottom:var(--clarify-dock-height,72px);}
|
||||
.messages.approval-open{padding-bottom:var(--approval-card-height,260px);scroll-padding-bottom:var(--approval-card-height,260px);transition:padding-bottom .22s cubic-bezier(.2,.8,.2,1);}
|
||||
.messages.approval-collapsed{padding-bottom:var(--approval-dock-height,72px);scroll-padding-bottom:var(--approval-dock-height,72px);}
|
||||
.messages.terminal-expanding-from-dock{transition:none!important;}
|
||||
.queue-card-inner{background:var(--surface);border:1px solid var(--border);border-bottom:none;border-radius:14px 14px 0 0;contain:paint;transform:translateY(100%);opacity:0;transition:transform .35s cubic-bezier(.32,.72,.16,1),opacity .2s ease;overflow:hidden;max-height:240px;overflow-y:auto;padding-bottom:4px;}
|
||||
.queue-card.visible .queue-card-inner{transform:translateY(0);opacity:1;}
|
||||
|
||||
98
tests/test_3007_approval_card_collapse.py
Normal file
98
tests/test_3007_approval_card_collapse.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _compact(text: str) -> str:
|
||||
return "".join(text.split())
|
||||
|
||||
|
||||
def test_toggle_approval_card_collapsed_defined():
|
||||
assert "function toggleApprovalCardCollapsed(" in MESSAGES_JS
|
||||
|
||||
|
||||
def test_sync_approval_collapse_button_defined():
|
||||
assert "function _syncApprovalCollapseButton(" in MESSAGES_JS
|
||||
|
||||
|
||||
def test_sync_approval_transcript_space_defined():
|
||||
assert "function _syncApprovalTranscriptSpace(" in MESSAGES_JS
|
||||
|
||||
|
||||
def test_approval_collapsed_toggle_in_messages_js():
|
||||
compact_js = _compact(MESSAGES_JS)
|
||||
assert 'card.classList.toggle("collapsed",collapsed)' in compact_js
|
||||
|
||||
|
||||
def test_approval_collapsed_cleared_in_hide():
|
||||
# hideApprovalCard must reset collapse state so next approval opens expanded
|
||||
compact_js = _compact(MESSAGES_JS)
|
||||
assert 'card.classList.remove("collapsed")' in compact_js
|
||||
|
||||
|
||||
def test_approval_signature_includes_approval_id():
|
||||
# A distinct queued approval must be distinguishable by approval_id so a new
|
||||
# pending approval is not treated as the same one (which would inherit a
|
||||
# prior collapsed state). (#3515 gate finding)
|
||||
compact_js = _compact(MESSAGES_JS)
|
||||
assert "approval_id:pending.approval_id||null" in compact_js
|
||||
|
||||
|
||||
def test_fresh_approval_renders_expanded():
|
||||
# In showApprovalCard's !sameApproval branch the collapsed class must be
|
||||
# cleared so a freshly displayed approval never hides its command/buttons.
|
||||
compact_js = _compact(MESSAGES_JS)
|
||||
marker = "if(!sameApproval){"
|
||||
idx = compact_js.find(marker)
|
||||
assert idx != -1, "expected the !sameApproval branch in showApprovalCard"
|
||||
# the collapsed-clear must appear within the branch body (before its closing brace)
|
||||
branch = compact_js[idx: idx + 400]
|
||||
assert 'card.classList.remove("collapsed")' in branch, (
|
||||
"a distinct approval must clear .collapsed inside the !sameApproval branch"
|
||||
)
|
||||
|
||||
|
||||
def test_messages_approval_open_in_css():
|
||||
assert ".messages.approval-open" in STYLE_CSS
|
||||
|
||||
|
||||
def test_messages_approval_collapsed_in_css():
|
||||
assert ".messages.approval-collapsed" in STYLE_CSS
|
||||
|
||||
|
||||
def test_approval_dock_height_padding_in_css():
|
||||
compact_css = _compact(STYLE_CSS)
|
||||
assert "padding-bottom:var(--approval-dock-height,72px)" in compact_css
|
||||
|
||||
|
||||
def test_approval_card_collapsed_header_margin_in_css():
|
||||
assert ".approval-card.collapsed .approval-header" in STYLE_CSS
|
||||
|
||||
|
||||
def test_approval_card_collapsed_desc_hidden_in_css():
|
||||
assert ".approval-card.collapsed .approval-desc" in STYLE_CSS
|
||||
|
||||
|
||||
def test_approval_collapse_button_in_html():
|
||||
assert 'id="approvalCollapse"' in INDEX_HTML
|
||||
|
||||
|
||||
def test_approval_collapse_aria_expanded_in_html():
|
||||
assert 'aria-expanded="true"' in INDEX_HTML
|
||||
|
||||
|
||||
def test_approval_collapse_onclick_in_html():
|
||||
assert 'onclick="toggleApprovalCardCollapsed()"' in INDEX_HTML
|
||||
|
||||
|
||||
def test_sync_approval_transcript_space_called_in_show_and_hide():
|
||||
# Must be called from both showApprovalCard and hideApprovalCard
|
||||
compact_js = _compact(MESSAGES_JS)
|
||||
assert compact_js.count("_syncApprovalTranscriptSpace(") >= 3 # show, hide, toggle
|
||||
assert "_syncApprovalTranscriptSpace(null)" in MESSAGES_JS
|
||||
# show mirrors the merged clarify card: mark visible first, then sync immediately so the transcript reserves space on first paint
|
||||
assert 'card.classList.add("visible");_syncApprovalCollapseButton(card);_syncApprovalTranscriptSpace(card,{immediate:true})' in compact_js
|
||||
Reference in New Issue
Block a user