From 98547b9e4e73852e868dc439ba31572549a3c026 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 15 Apr 2026 00:56:39 +0200 Subject: [PATCH] feat(ai): freitext feedback on proposal rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rejecting a ghost-card proposal now reveals an inline textarea. Trimmed feedback lands in `proposal.userFeedback` where the next Planner iteration already reads it via iteration history — the AI learns from concrete rejection reasons without needing a settings UI. - Click "Ablehnen" → textarea + Cancel/Reject buttons replace the approve/reject row - Empty submission still rejects (userFeedback stays undefined so the Planner sees "no reason given" rather than an empty string) - `rejectingId` + `rejectDraft` are per-inbox local state; only one reject form is open at a time Co-Authored-By: Claude Opus 4.6 (1M context) --- .../lib/components/ai/AiProposalInbox.svelte | 121 ++++++++++++++---- 1 file changed, 97 insertions(+), 24 deletions(-) diff --git a/apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte b/apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte index 8a545861d..00fdd118f 100644 --- a/apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte +++ b/apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte @@ -32,6 +32,9 @@ const proposals = $derived(useAiProposals({ status: 'pending', module })); let busyId = $state(null); + /** Proposal whose reject-feedback textarea is currently open. */ + let rejectingId = $state(null); + let rejectDraft = $state(''); async function handleApprove(p: Proposal) { busyId = p.id; @@ -44,10 +47,25 @@ } } - async function handleReject(p: Proposal) { + function openRejectForm(p: Proposal) { + rejectingId = p.id; + rejectDraft = ''; + } + + function cancelReject() { + rejectingId = null; + rejectDraft = ''; + } + + async function confirmReject(p: Proposal) { busyId = p.id; try { - await rejectProposal(p.id); + // Trimmed feedback, or undefined when empty — downstream planner + // sees the field as absent rather than as an empty string. + const feedback = rejectDraft.trim().length > 0 ? rejectDraft.trim() : undefined; + await rejectProposal(p.id, feedback); + rejectingId = null; + rejectDraft = ''; } catch (err) { console.error('[AiProposalInbox] reject failed:', err); } finally { @@ -82,28 +100,50 @@

{p.rationale}

{/if} -
- - -
+ {#if rejectingId === p.id} +
(e.preventDefault(), confirmReject(p))}> + + +
+ + +
+
+ {:else} + + {/if} {/each} @@ -199,4 +239,37 @@ .btn.approve:hover:not(:disabled) { background: color-mix(in oklab, var(--color-primary, #6b5bff) 20%, var(--color-bg, #fff)); } + + .reject-form { + display: flex; + flex-direction: column; + gap: 0.375rem; + margin-top: 0.625rem; + } + .reject-label { + font-size: 0.75rem; + color: var(--color-muted, #666); + } + .reject-form textarea { + padding: 0.375rem 0.5rem; + border: 1px solid var(--color-border, #ddd); + border-radius: 0.375rem; + font: inherit; + resize: vertical; + background: var(--color-bg, #fff); + color: var(--color-fg, inherit); + } + .reject-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + } + .btn.reject-confirm { + background: #fff0f0; + border-color: #e99; + color: #8a1b1b; + } + .btn.reject-confirm:hover:not(:disabled) { + background: #ffe4e4; + }