feat(ai): inline proposal inbox in the todo module

First pilot of the AI Workbench ghost-state pattern. A reusable
`<AiProposalInbox module="todo" />` component renders pending proposals
for a given module as dashed-outline ghost cards above the real content —
zero UI when the AI is idle, approve / reject inline when it's not.

- `data/ai/proposals/queries.ts` — reactive `useAiProposals` live query
  with module / status / missionId filters. Module filter resolves via
  the tool registry so each proposal auto-routes to the right page.
- `components/ai/AiProposalInbox.svelte` — the drop-in inbox component.
  Shows tool description + params + AI rationale; approve runs the
  original intent under the AI actor context (preserving attribution),
  reject stores the row with status=rejected for the next planner pass.
- Wired into /todo for the pilot. Other modules opt in by adding one
  line once their tools land in DEFAULT_AI_POLICY.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 20:58:46 +02:00
parent 513e3c7496
commit 7a1f11c971
4 changed files with 339 additions and 0 deletions

View file

@ -0,0 +1,202 @@
<!--
AiProposalInbox — renders pending AI proposals inline inside a module.
Drop this component anywhere in a module page. It shows zero UI when no
proposals are pending (so the module layout is unaffected when the AI is
idle) and a list of approval cards when the AI has staged intents.
Each card displays:
- the rationale ("why" from the AI)
- a human-readable preview of the intent (tool name + params)
- Approve / Reject buttons that run `approveProposal` / `rejectProposal`
and close themselves via Dexie's live query
The cards use a "ghost" style (semi-transparent, dashed border) so they
read as "not real yet" — inspired by Figma's multiplayer cursors and
Google Docs suggestions.
-->
<script lang="ts">
import { Check, X, Sparkle } from '@mana/shared-icons';
import { useAiProposals } from '$lib/data/ai/proposals/queries';
import { approveProposal, rejectProposal } from '$lib/data/ai/proposals/store';
import { getTool } from '$lib/data/tools/registry';
import type { Proposal } from '$lib/data/ai/proposals/types';
interface Props {
/** Filter proposals to tools belonging to this module (e.g. 'todo'). */
module: string;
}
let { module }: Props = $props();
const proposals = $derived(useAiProposals({ status: 'pending', module }));
let busyId = $state<string | null>(null);
async function handleApprove(p: Proposal) {
busyId = p.id;
try {
await approveProposal(p.id);
} catch (err) {
console.error('[AiProposalInbox] approve failed:', err);
} finally {
busyId = null;
}
}
async function handleReject(p: Proposal) {
busyId = p.id;
try {
await rejectProposal(p.id);
} catch (err) {
console.error('[AiProposalInbox] reject failed:', err);
} finally {
busyId = null;
}
}
function formatIntent(p: Proposal): string {
if (p.intent.kind !== 'toolCall') return JSON.stringify(p.intent);
const tool = getTool(p.intent.toolName);
const label = tool?.description ?? p.intent.toolName;
const paramsPreview = Object.entries(p.intent.params)
.filter(([, v]) => v !== undefined && v !== null && v !== '')
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join(' · ');
return paramsPreview ? `${label} — ${paramsPreview}` : label;
}
</script>
{#if proposals.value.length > 0}
<section class="inbox" aria-label="Vorschläge der KI">
{#each proposals.value as p (p.id)}
<article class="card" class:busy={busyId === p.id}>
<header class="header">
<Sparkle size={16} weight="fill" />
<span class="label">KI schlägt vor</span>
</header>
<p class="intent">{formatIntent(p)}</p>
{#if p.rationale}
<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>
</article>
{/each}
</section>
{/if}
<style>
.inbox {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.card {
position: relative;
padding: 0.75rem 1rem;
border: 1px dashed color-mix(in oklab, var(--color-primary, #6b5bff) 55%, transparent);
border-radius: 0.625rem;
background: color-mix(in oklab, var(--color-primary, #6b5bff) 6%, var(--color-bg, transparent));
transition: opacity 150ms ease;
}
.card.busy {
opacity: 0.55;
pointer-events: none;
}
.header {
display: flex;
align-items: center;
gap: 0.375rem;
color: color-mix(in oklab, var(--color-primary, #6b5bff) 85%, var(--color-fg, #000));
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.intent {
margin: 0.375rem 0 0;
font-size: 0.9375rem;
line-height: 1.4;
color: var(--color-fg, inherit);
}
.rationale {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
line-height: 1.35;
color: var(--color-muted, #666);
font-style: italic;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.625rem;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 0.375rem;
background: var(--color-bg, #fff);
color: var(--color-fg, inherit);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition:
background 120ms ease,
border-color 120ms ease;
}
.btn:hover:not(:disabled) {
border-color: color-mix(in oklab, var(--color-fg, #000) 30%, transparent);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.btn.approve {
background: color-mix(in oklab, var(--color-primary, #6b5bff) 12%, var(--color-bg, #fff));
border-color: color-mix(in oklab, var(--color-primary, #6b5bff) 45%, transparent);
color: color-mix(in oklab, var(--color-primary, #6b5bff) 85%, var(--color-fg, #000));
}
.btn.approve:hover:not(:disabled) {
background: color-mix(in oklab, var(--color-primary, #6b5bff) 20%, var(--color-bg, #fff));
}
</style>

View file

@ -0,0 +1,91 @@
import 'fake-indexeddb/auto';
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() }));
vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() }));
vi.mock('$lib/triggers/inline-suggest', () => ({
checkInlineSuggestion: vi.fn().mockResolvedValue(null),
}));
import { db } from '../../database';
import { registerTools } from '../../tools/registry';
import { createProposal } from './store';
import { PROPOSALS_TABLE } from './types';
import type { Actor } from '../../events/actor';
// Register two tools in distinct modules so the `module` filter has
// something to discriminate against.
registerTools([
{
name: 'queries_test_todo_op',
module: 'todo',
description: 'd',
parameters: [],
async execute() {
return { success: true, message: 'ok' };
},
},
{
name: 'queries_test_calendar_op',
module: 'calendar',
description: 'd',
parameters: [],
async execute() {
return { success: true, message: 'ok' };
},
},
]);
const AI: Extract<Actor, { kind: 'ai' }> = {
kind: 'ai',
missionId: 'm-a',
iterationId: 'i-a',
rationale: 'r',
};
beforeEach(async () => {
await db.table(PROPOSALS_TABLE).clear();
});
describe('proposal filters (logic used by useAiProposals)', () => {
it('filters by module via the tool registry lookup', async () => {
await createProposal({
actor: AI,
intent: { kind: 'toolCall', toolName: 'queries_test_todo_op', params: {} },
rationale: 'r',
});
await createProposal({
actor: AI,
intent: { kind: 'toolCall', toolName: 'queries_test_calendar_op', params: {} },
rationale: 'r',
});
// Replicate the filter logic used inside the live query
const { getTool } = await import('../../tools/registry');
const all = await db.table(PROPOSALS_TABLE).toArray();
const todoOnly = all.filter((p) => {
if (p.intent.kind !== 'toolCall') return false;
const tool = getTool(p.intent.toolName);
return tool?.module === 'todo';
});
expect(todoOnly).toHaveLength(1);
expect(todoOnly[0].intent.toolName).toBe('queries_test_todo_op');
});
it('filters by missionId', async () => {
await createProposal({
actor: AI,
intent: { kind: 'toolCall', toolName: 'queries_test_todo_op', params: {} },
rationale: 'r',
});
await createProposal({
actor: { ...AI, missionId: 'm-b' },
intent: { kind: 'toolCall', toolName: 'queries_test_todo_op', params: {} },
rationale: 'r',
});
const all = await db.table(PROPOSALS_TABLE).toArray();
const onlyA = all.filter((p) => p.missionId === 'm-a');
expect(onlyA).toHaveLength(1);
});
});

View file

@ -0,0 +1,42 @@
/**
* Reactive queries over the `pendingProposals` Dexie table.
*
* Used by the AI workbench UI and by per-module proposal inboxes so each
* module can render the AI's staged intents inline.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '../../database';
import { getTool } from '../../tools/registry';
import type { Proposal, ProposalStatus } from './types';
import { PROPOSALS_TABLE } from './types';
export interface UseProposalsOptions {
/** Filter by lifecycle state. Defaults to `'pending'`. */
status?: ProposalStatus;
/** Filter to proposals whose intent targets tools in this module. */
module?: string;
/** Filter to a specific mission. */
missionId?: string;
}
/**
* Svelte 5 live query returning proposals matching the given filter.
* Re-runs whenever `pendingProposals` changes.
*/
export function useAiProposals(options: UseProposalsOptions = {}) {
const { status = 'pending', module, missionId } = options;
return useLiveQueryWithDefault(async () => {
const all = await db.table<Proposal>(PROPOSALS_TABLE).orderBy('createdAt').reverse().toArray();
return all.filter((p) => {
if (p.status !== status) return false;
if (missionId && p.missionId !== missionId) return false;
if (module) {
if (p.intent.kind !== 'toolCall') return false;
const tool = getTool(p.intent.toolName);
if (!tool || tool.module !== module) return false;
}
return true;
});
}, [] as Proposal[]);
}

View file

@ -15,6 +15,7 @@
import OnboardingModal from '$lib/modules/todo/components/OnboardingModal.svelte';
import TodoPage from '$lib/modules/todo/components/pages/TodoPage.svelte';
import PagePicker from '$lib/modules/todo/components/pages/PagePicker.svelte';
import AiProposalInbox from '$lib/components/ai/AiProposalInbox.svelte';
import { todoSettings } from '$lib/modules/todo/stores/settings.svelte';
import type { PageConfig } from '$lib/modules/todo/stores/settings.svelte';
import { getTaskStats } from '$lib/modules/todo';
@ -237,6 +238,9 @@
</div>
</header>
<!-- AI proposals awaiting approval -->
<AiProposalInbox module="todo" />
<!-- Quick Add -->
<div class="quick-add-wrapper">
<QuickAddTask labels={allLabels} onShowSyntaxHelp={() => (showSyntaxHelp = true)} />