From 7a1f11c97110c54283395d0d3054c223ee46a9e8 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 14 Apr 2026 20:58:46 +0200 Subject: [PATCH] feat(ai): inline proposal inbox in the todo module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First pilot of the AI Workbench ghost-state pattern. A reusable `` 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) --- .../lib/components/ai/AiProposalInbox.svelte | 202 ++++++++++++++++++ .../src/lib/data/ai/proposals/queries.test.ts | 91 ++++++++ .../web/src/lib/data/ai/proposals/queries.ts | 42 ++++ .../web/src/routes/(app)/todo/+page.svelte | 4 + 4 files changed, 339 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte create mode 100644 apps/mana/apps/web/src/lib/data/ai/proposals/queries.test.ts create mode 100644 apps/mana/apps/web/src/lib/data/ai/proposals/queries.ts diff --git a/apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte b/apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte new file mode 100644 index 000000000..8a545861d --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte @@ -0,0 +1,202 @@ + + + +{#if proposals.value.length > 0} +
+ {#each proposals.value as p (p.id)} +
+
+ + KI schlägt vor +
+ +

{formatIntent(p)}

+ + {#if p.rationale} +

{p.rationale}

+ {/if} + +
+ + +
+
+ {/each} +
+{/if} + + diff --git a/apps/mana/apps/web/src/lib/data/ai/proposals/queries.test.ts b/apps/mana/apps/web/src/lib/data/ai/proposals/queries.test.ts new file mode 100644 index 000000000..88a367eb8 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/proposals/queries.test.ts @@ -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 = { + 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); + }); +}); diff --git a/apps/mana/apps/web/src/lib/data/ai/proposals/queries.ts b/apps/mana/apps/web/src/lib/data/ai/proposals/queries.ts new file mode 100644 index 000000000..d49ef5e22 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/proposals/queries.ts @@ -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(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[]); +} diff --git a/apps/mana/apps/web/src/routes/(app)/todo/+page.svelte b/apps/mana/apps/web/src/routes/(app)/todo/+page.svelte index c6dac7422..226a8e8e0 100644 --- a/apps/mana/apps/web/src/routes/(app)/todo/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/todo/+page.svelte @@ -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 @@ + + +
(showSyntaxHelp = true)} />