feat(ai): freitext feedback on proposal rejection

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 00:56:39 +02:00
parent 9bc44c075a
commit 98547b9e4e

View file

@ -32,6 +32,9 @@
const proposals = $derived(useAiProposals({ status: 'pending', module }));
let busyId = $state<string | null>(null);
/** Proposal whose reject-feedback textarea is currently open. */
let rejectingId = $state<string | null>(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 class="rationale">{p.rationale}</p>
{/if}
<footer class="actions">
<button
type="button"
class="btn reject"
disabled={busyId !== null}
onclick={() => handleReject(p)}
aria-label="Ablehnen"
>
<X size={16} weight="bold" />
<span>Ablehnen</span>
</button>
<button
type="button"
class="btn approve"
disabled={busyId !== null}
onclick={() => handleApprove(p)}
aria-label="Übernehmen"
>
<Check size={16} weight="bold" />
<span>Übernehmen</span>
</button>
</footer>
{#if rejectingId === p.id}
<form class="reject-form" onsubmit={(e) => (e.preventDefault(), confirmReject(p))}>
<label class="reject-label" for={`reject-${p.id}`}>
Warum ablehnen? (optional — hilft der KI beim nächsten Versuch)
</label>
<textarea
id={`reject-${p.id}`}
bind:value={rejectDraft}
rows="2"
placeholder="z.B. zu aggressiv, oder: falsches Datum gewählt"
></textarea>
<div class="reject-actions">
<button type="button" class="btn" disabled={busyId !== null} onclick={cancelReject}>
Abbrechen
</button>
<button type="submit" class="btn reject-confirm" disabled={busyId !== null}>
Ablehnen
</button>
</div>
</form>
{:else}
<footer class="actions">
<button
type="button"
class="btn reject"
disabled={busyId !== null}
onclick={() => openRejectForm(p)}
aria-label="Ablehnen"
>
<X size={16} weight="bold" />
<span>Ablehnen</span>
</button>
<button
type="button"
class="btn approve"
disabled={busyId !== null}
onclick={() => handleApprove(p)}
aria-label="Übernehmen"
>
<Check size={16} weight="bold" />
<span>Übernehmen</span>
</button>
</footer>
{/if}
</article>
{/each}
</section>
@ -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;
}
</style>