mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
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:
parent
513e3c7496
commit
7a1f11c971
4 changed files with 339 additions and 0 deletions
202
apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte
Normal file
202
apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte
Normal 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>
|
||||
91
apps/mana/apps/web/src/lib/data/ai/proposals/queries.test.ts
Normal file
91
apps/mana/apps/web/src/lib/data/ai/proposals/queries.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
42
apps/mana/apps/web/src/lib/data/ai/proposals/queries.ts
Normal file
42
apps/mana/apps/web/src/lib/data/ai/proposals/queries.ts
Normal 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[]);
|
||||
}
|
||||
|
|
@ -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)} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue