From c7aa98970dc9212d1ecf4e373d1f386e64470255 Mon Sep 17 00:00:00 2001 From: Buddy Delaune Date: Sun, 5 Jul 2026 13:05:27 -0500 Subject: [PATCH 1/2] feat(usage): split compact/reset buttons, fix reviewer blockers Adds a session-usage dashboard action to compact (Discord only) or reset (all sessions) a running session, with separate confirm modals, proper failure propagation, and per-session concurrency locking. Addresses two rounds of review feedback: - Version bumps, Cancel disabled during in-flight ops, compact failure surfaced to the client, Compact restricted to Discord sessions, per-session concurrency lock - Inverted onCompactCancel guard fixed (Cancel now hides instead of a broken disabled-check), missing .usage-compact-btn styling added, web-session reset now clears sessions.ts's in-memory `current` cache via resetSession() so a still-running runner can't resume a stale sessionId after reset --- src/ui/page/script.ts | 155 +++++++++++++++++++++++++++++++++++++++ src/ui/page/styles.ts | 117 +++++++++++++++++++++++++++++ src/ui/page/template.ts | 33 +++++++++ src/ui/server.ts | 30 +++++++- src/ui/services/usage.ts | 72 +++++++++++++----- 5 files changed, 388 insertions(+), 19 deletions(-) diff --git a/src/ui/page/script.ts b/src/ui/page/script.ts index ae4c5981..1d91d22c 100644 --- a/src/ui/page/script.ts +++ b/src/ui/page/script.ts @@ -1610,6 +1610,10 @@ export const pageScript = String.raw` // --- Token management (Task 1.1) --- "" + "" + s.turnCount + "" + "" + fmtRelative(s.lastUsedAt) + "" + + "" + + (s.channel === "discord" ? " " : "") + + "" + + "" + ""; }).join(""); usageWrap.innerHTML = @@ -1623,11 +1627,162 @@ export const pageScript = String.raw` // --- Token management (Task 1.1) --- "Est. Cost" + "Turns" + "Last Active" + + "" + "" + "" + rows + "" + ""; } + // Per-session in-flight lock — prevents concurrent compact/reset on the same session. + var inFlightSessions = new Set(); + + if (usageWrap) { + // ── Compact flow ────────────────────────────────────────────────────────── + usageWrap.addEventListener("click", function(event) { + var btn = event.target && event.target.closest && event.target.closest("[data-compact-session]"); + if (!btn || !(btn instanceof HTMLButtonElement)) return; + var sessionId = btn.getAttribute("data-compact-session") || ""; + var label = btn.getAttribute("data-compact-label") || sessionId; + if (!sessionId || inFlightSessions.has(sessionId)) return; + var compactModal = document.getElementById("confirm-compact-modal"); + var compactLabelEl = document.getElementById("confirm-compact-label"); + var compactOk = document.getElementById("confirm-compact-ok"); + var compactCancel = document.getElementById("confirm-compact-cancel"); + var compactProgress = document.getElementById("confirm-compact-progress"); + var compactStatus = document.getElementById("confirm-compact-status"); + if (!compactModal || !compactLabelEl || !compactOk || !compactCancel) return; + compactLabelEl.textContent = label; + compactModal.classList.add("open"); + compactModal.setAttribute("aria-hidden", "false"); + var statusTimer = null; + var statusFadeTimer = null; + var statusMessages = [ + "Compacting conversation history…", + "Summarizing and condensing the session…", + "Still working, hang tight…", + "Almost there…", + ]; + var startStatusCycle = function() { + if (!compactStatus) return; + var idx = 0; + var showNext = function() { + compactStatus.classList.remove("visible"); + statusFadeTimer = setTimeout(function() { + compactStatus.textContent = statusMessages[idx % statusMessages.length]; + compactStatus.classList.add("visible"); + idx++; + statusTimer = setTimeout(showNext, 4000); + }, 350); + }; + showNext(); + }; + var stopStatusCycle = function() { + if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; } + if (statusFadeTimer) { clearTimeout(statusFadeTimer); statusFadeTimer = null; } + if (compactStatus) { compactStatus.classList.remove("visible"); compactStatus.textContent = ""; } + }; + var cleanupCompact = function() { + stopStatusCycle(); + inFlightSessions.delete(sessionId); + compactModal.classList.remove("open"); + compactModal.setAttribute("aria-hidden", "true"); + if (compactProgress) compactProgress.classList.remove("active"); + compactOk.removeEventListener("click", onCompactOk); + compactCancel.removeEventListener("click", onCompactCancel); + compactOk.disabled = false; + compactOk.textContent = "Compact"; + compactCancel.style.display = ""; + btn.disabled = false; + btn.textContent = "Compact"; + }; + var onCompactCancel = function() { + cleanupCompact(); + }; + var onCompactOk = function() { + inFlightSessions.add(sessionId); + compactOk.disabled = true; + compactOk.textContent = "Working…"; + compactCancel.style.display = "none"; + if (compactProgress) compactProgress.classList.add("active"); + startStatusCycle(); + btn.disabled = true; + btn.textContent = "Compacting…"; + fetch("/api/usage/" + encodeURIComponent(sessionId) + "/compact", { method: "POST" }) + .then(function(r) { return r.json(); }) + .then(function(data) { + cleanupCompact(); + if (data.ok) { + fetchUsage(); + } else { + alert("Compact failed: " + (data.error || data.message || "unknown error")); + } + }) + .catch(function(err) { + cleanupCompact(); + alert("Compact failed: " + String(err)); + }); + }; + compactOk.addEventListener("click", onCompactOk); + compactCancel.addEventListener("click", onCompactCancel); + }); + + // ── Reset flow ──────────────────────────────────────────────────────────── + usageWrap.addEventListener("click", function(event) { + var btn = event.target && event.target.closest && event.target.closest("[data-reset-session]"); + if (!btn || !(btn instanceof HTMLButtonElement)) return; + var sessionId = btn.getAttribute("data-reset-session") || ""; + var label = btn.getAttribute("data-reset-label") || sessionId; + if (!sessionId || inFlightSessions.has(sessionId)) return; + var confirmModal = document.getElementById("confirm-reset-modal"); + var confirmLabelEl = document.getElementById("confirm-reset-label"); + var confirmOk = document.getElementById("confirm-reset-ok"); + var confirmCancel = document.getElementById("confirm-reset-cancel"); + if (!confirmModal || !confirmLabelEl || !confirmOk || !confirmCancel) return; + confirmLabelEl.textContent = label; + confirmModal.classList.add("open"); + confirmModal.setAttribute("aria-hidden", "false"); + var cleanupReset = function() { + inFlightSessions.delete(sessionId); + confirmModal.classList.remove("open"); + confirmModal.setAttribute("aria-hidden", "true"); + confirmOk.removeEventListener("click", onResetOk); + confirmCancel.removeEventListener("click", onResetCancel); + confirmOk.disabled = false; + confirmOk.textContent = "Reset"; + confirmCancel.style.display = ""; + btn.disabled = false; + btn.textContent = "Reset"; + }; + var onResetCancel = function() { + cleanupReset(); + }; + var onResetOk = function() { + inFlightSessions.add(sessionId); + confirmOk.disabled = true; + confirmOk.textContent = "Resetting…"; + confirmCancel.style.display = "none"; + btn.disabled = true; + btn.textContent = "Resetting…"; + fetch("/api/usage/" + encodeURIComponent(sessionId) + "/reset", { method: "DELETE" }) + .then(function(r) { return r.json(); }) + .then(function(data) { + cleanupReset(); + if (data.ok) { + fetchUsage(); + } else { + alert("Reset failed: " + (data.error || "unknown error")); + } + }) + .catch(function(err) { + cleanupReset(); + alert("Reset failed: " + String(err)); + }); + }; + confirmOk.addEventListener("click", onResetOk); + confirmCancel.addEventListener("click", onResetCancel); + }); + } + function fetchUsage() { fetch("/api/usage") .then(function(r) { return r.json(); }) diff --git a/src/ui/page/styles.ts b/src/ui/page/styles.ts index 2596805c..8cea6621 100644 --- a/src/ui/page/styles.ts +++ b/src/ui/page/styles.ts @@ -964,6 +964,96 @@ export const pageStyles = String.raw` :root { background: linear-gradient(180deg, #a9d4ff, #789fce); } + .confirm-card { + width: min(440px, 100%); + border: 1px solid #d8e4ff20; + border-radius: 16px; + background: #0b1220f2; + box-shadow: 0 20px 44px #00000066; + display: flex; + flex-direction: column; + overflow: hidden; + } + .confirm-body { + padding: 18px 18px 14px; + display: flex; + flex-direction: column; + gap: 8px; + } + .confirm-msg { + margin: 0; + font-size: 15px; + color: #ccd9f5; + } + .confirm-msg strong { + color: #e8f0ff; + } + .confirm-sub { + margin: 0; + font-size: 13px; + color: #7a90b2; + line-height: 1.5; + } + .confirm-note { + margin: 0; + font-size: 12px; + color: #566880; + font-style: italic; + } + .confirm-warn { color: rgba(239, 68, 68, 0.85); } + .confirm-status { + min-height: 18px; + font-size: 13px; + color: #4db87a; + font-family: "JetBrains Mono", monospace; + letter-spacing: 0.02em; + opacity: 0; + transition: opacity 0.35s ease; + } + .confirm-status.visible { + opacity: 1; + } + .confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 14px 18px; + border-top: 1px solid #ffffff12; + } + .hb-btn.solid.danger { + border-color: #c8303080; + background: linear-gradient(180deg, #8b1a1ad4 0%, #6b1414ce 100%); + color: #f8c8c8; + } + .hb-btn.solid.danger:hover { + filter: brightness(1.08); + } + .hb-btn.solid.danger:disabled { + opacity: 0.55; + cursor: default; + filter: none; + } + .confirm-progress { + height: 3px; + background: #ffffff08; + overflow: hidden; + opacity: 0; + transition: opacity 0.2s; + } + .confirm-progress.active { + opacity: 1; + } + .confirm-progress-fill { + height: 100%; + width: 40%; + background: linear-gradient(90deg, transparent, #3cb879cc, transparent); + animation: progress-sweep 1.6s ease-in-out infinite; + } + @keyframes progress-sweep { + 0% { transform: translateX(-200%); } + 100% { transform: translateX(350%); } + } + .dock-shell { position: fixed; left: 50%; @@ -1683,6 +1773,33 @@ export const pageStyles = String.raw` :root { transition: width 0.3s ease; } .usage-cost-label { position: relative; padding-left: 6px; font-variant-numeric: tabular-nums; color: var(--fg); } +.usage-td-reset { text-align: right; padding-right: 0; width: 1%; white-space: nowrap; } +.usage-reset-btn { + font-size: 10px; + font-family: inherit; + padding: 3px 8px; + border-radius: 4px; + border: 1px solid rgba(239, 68, 68, 0.35); + background: rgba(239, 68, 68, 0.08); + color: rgba(239, 68, 68, 0.75); + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.usage-reset-btn:hover { background: rgba(239, 68, 68, 0.18); color: rgb(239, 68, 68); border-color: rgba(239, 68, 68, 0.6); } +.usage-reset-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.usage-compact-btn { + font-size: 10px; + font-family: inherit; + padding: 3px 8px; + border-radius: 4px; + border: 1px solid rgba(99, 179, 237, 0.35); + background: rgba(99, 179, 237, 0.08); + color: rgba(99, 179, 237, 0.75); + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.usage-compact-btn:hover { background: rgba(99, 179, 237, 0.18); color: rgb(99, 179, 237); border-color: rgba(99, 179, 237, 0.6); } +.usage-compact-btn:disabled { opacity: 0.45; cursor: not-allowed; } @media (max-width: 640px) { .tab-btn { padding: 0 12px; font-size: 10px; } diff --git a/src/ui/page/template.ts b/src/ui/page/template.ts index eec4dd26..4e09798c 100644 --- a/src/ui/page/template.ts +++ b/src/ui/page/template.ts @@ -111,6 +111,39 @@ ${pageStyles} + +