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)} />