diff --git a/CHANGELOG.md b/CHANGELOG.md
index db622dbc..d9b2bc96 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/static/index.html b/static/index.html
index 1a44b24d..dc8c8725 100644
--- a/static/index.html
+++ b/static/index.html
@@ -456,11 +456,12 @@
-
+
Allow once
diff --git a/static/messages.js b/static/messages.js
index 5fa0d2e0..d6c78f1b 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -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;
diff --git a/static/style.css b/static/style.css
index 5d9ad0ac..6d47aa18 100644
--- a/static/style.css
+++ b/static/style.css
@@ -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;}
diff --git a/tests/test_3007_approval_card_collapse.py b/tests/test_3007_approval_card_collapse.py
new file mode 100644
index 00000000..94cc181e
--- /dev/null
+++ b/tests/test_3007_approval_card_collapse.py
@@ -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