feat(agent): one-tap "clear the proposals I didn't approve" (BoXon feedback) (#1798)

When the assistant stages several verifikat and the user approves only some,
the rest lingered as pending_operations in Granskning until the 30-day expiry —
manual per-item cleanup. Now:

- lib/agent/pending/reject-conversation-pending.ts rejects a conversation's
  still-pending proposals in one update (guarded on status='pending' so it never
  stamps over a committed verifikat; company-scoped; keyed on the conversation).
- POST /api/agent/conversations/[id]/reject-pending — the chat's "Rensa förslag
  som inte godkänts" button (appears when the thread has staged proposals; drops
  the cards from view).
- Auto-clear on archive: archiving a thread ("I'm done") clears its leftover
  proposals in the PATCH, best-effort.

Kept durable-by-default (proposals still come back on resume) — the button is
explicit user intent, not an auto-reject on every panel close, so resume still
works. 10 tests (helper + endpoint 401/404/404/happy); lint + guards + scoped
typecheck clean. UI button awaits founder visual sign-off.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-22 10:42:22 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Opus 4.8
parent 13b69a2056
commit 1f7acbf144
7 changed files with 243 additions and 0 deletions
+36
View File
@@ -206,6 +206,26 @@ export default function AgentChat({
onStatus,
}: AgentChatProps) {
const [conversationId, setConversationId] = useState<string | null>(initialConversationId ?? null)
// "Clear the proposals I didn't approve" — rejects the conversation's
// still-pending staged operations in one call so they don't linger in
// Granskning, then drops the cards from view.
const [clearingProposals, setClearingProposals] = useState(false)
async function handleClearProposals() {
const convId = conversationId
if (!convId || clearingProposals) return
setClearingProposals(true)
try {
await fetch(`/api/agent/conversations/${convId}/reject-pending`, { method: 'POST' })
// Committed proposals are already booked (their card was only a
// confirmation); pending ones the server just rejected. Either way, drop
// the cards so the conversation reads as resolved.
setMessages((prev) => prev.map((m) => (m.staged ? { ...m, staged: undefined } : m)))
} catch {
// best-effort: leave the cards if the request failed
} finally {
setClearingProposals(false)
}
}
// Track whether the first-turn callback has fired so the bootstrap
// starters get exactly one notification even if a turn fires before
// the conversation_id event (defensive: order shouldn't matter).
@@ -867,6 +887,22 @@ export default function AgentChat({
)}
</div>
{/* One-tap cleanup: when the assistant staged several proposals and the
user only approved some, clear the rest here instead of rejecting each
one in Granskning. */}
{conversationId && messages.some((m) => m.staged && m.staged.length > 0) && (
<div className="flex justify-end border-t border-border px-5 py-2">
<button
type="button"
onClick={() => void handleClearProposals()}
disabled={clearingProposals}
className="text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
{clearingProposals ? 'Rensar…' : 'Rensa förslag som inte godkänts'}
</button>
</div>
)}
{/* Paywall: /api/agent/invoke 403s without the ai capability. Replace
the composer with an upsell so an already-open conversation (or a
deep link to /chat/*) never offers an input that can't send. */}