Release v0.51.288 — Release JD (stage-r24 — collapsible approval card #3515) (#3697)
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:
nesquena-hermes
2026-06-05 19:52:52 -07:00
committed by GitHub
parent 0b223e91bc
commit d4f26f5156
5 changed files with 182 additions and 2 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;}

View 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