feat(ai-agents): missions lookup + simple policy + agent fingerprint (UX 3-6)

Four UX improvements that make agents more discoverable and their
behavior more transparent. All svelte-check clean (0 errors in
changed files).

=== UX 3: Reverse-Mission-Lookup ===

Agent detail view now shows a "Missions (N)" section listing all
missions owned by this agent with status dots + state labels. Includes
a "+ Neue Mission für [agent]" button that navigates to the template
gallery. Users no longer have to mentally cross-reference between the
agents and missions modules.

=== UX 4: Simple-Mode Policy ===

The Policy section defaults to three radio-card presets:
- Standard (Vorschlag für alles)
- Vorsichtig (alles Vorschlag, keine Auto-Writes)
- Aggressiv (gleichartige Schreibvorgänge automatisch)

The full per-module matrix is now hidden behind "▸ Erweitert
anzeigen". This covers 90% of users who just want a quick
conservative/aggressive toggle. Power-users still get the full
matrix.

=== UX 5: Policy Natural Language Summary ===

Above the preset radios, a preformatted block summarizes the current
policy in plain German: "Gesperrt: X, Y · calendar: automatisch ·
Alles andere: Vorschlag". Generated from the policy object. Updates
live when the user switches presets or changes module overrides.

=== UX 6: Agent Fingerprint on List-Views ===

New <AgentDot record={item} /> component: reads __lastActor from
any Dexie record, resolves the agent's avatar via the live useAgents
query, and renders a tiny inline emoji dot next to the item title.
When no AI actor wrote the record, renders nothing (zero-width).

Wired into:
- /todo — task title row, after the title span
- /notes — note title row, after the title span

Each module import is a single line (`import AgentDot from ...`);
the component is self-contained (owns its own query + styles).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-16 15:22:55 +02:00
parent 26e1c4774f
commit fabf259526
4 changed files with 323 additions and 62 deletions

View file

@ -0,0 +1,65 @@
<!--
AgentDot — tiny inline indicator showing which AI agent last wrote
a record. Reads the __lastActor field stamped by the Dexie hooks;
when kind='ai', resolves the agent's avatar from the live agent
query. When no AI actor is present, renders nothing.
Usage:
<AgentDot record={task} />
<AgentDot record={note} />
Intentionally minimal: one emoji dot + tooltip. No click handler
(future: could navigate to the agent detail). Designed to be
dropped into any list-item without disrupting layout — the wrapper
is inline with zero width when hidden.
-->
<script lang="ts">
import { useAgents } from '$lib/data/ai/agents/queries';
import { normalizeActor } from '$lib/data/events/actor';
interface Props {
/** Any Dexie record that might carry __lastActor. */
record: unknown;
}
const { record }: Props = $props();
const agents = $derived(useAgents({ state: 'active' }));
const agentById = $derived(new Map(agents.value.map((a) => [a.id, a])));
const actor = $derived(() => {
const raw = (record as Record<string, unknown>)?.__lastActor;
if (!raw) return null;
return normalizeActor(raw);
});
const resolved = $derived(() => {
const a = actor();
if (!a || a.kind !== 'ai') return null;
const agent = agentById.get(a.principalId);
return {
avatar: agent?.avatar ?? '🤖',
name: agent?.name ?? a.displayName,
};
});
</script>
{#if resolved()}
<span class="agent-dot" title="Erstellt von {resolved()?.name}">
{resolved()?.avatar}
</span>
{/if}
<style>
.agent-dot {
display: inline-block;
font-size: 0.6875rem;
line-height: 1;
opacity: 0.7;
flex-shrink: 0;
cursor: default;
}
.agent-dot:hover {
opacity: 1;
}
</style>

View file

@ -17,9 +17,11 @@
handle the common "let the agent touch todo but not calendar" case.
-->
<script lang="ts">
import { ArrowLeft, Plus, Pause, Play, Archive, Trash, Sparkle } from '@mana/shared-icons';
import { ArrowLeft, Plus, Pause, Play, Archive, Trash, Sparkle, Flag } from '@mana/shared-icons';
import { goto } from '$app/navigation';
import { useAgents } from '$lib/data/ai/agents/queries';
import { useMissions } from '$lib/data/ai/missions/queries';
import type { Mission } from '$lib/data/ai/missions/types';
import { DEFAULT_AGENT_ID } from '@mana/shared-ai';
import {
createAgent,
@ -131,10 +133,17 @@
}
}
// ── Missions for this agent ──────────────────────────────
const allMissions = $derived(useMissions());
const agentMissions = $derived(
selected ? allMissions.value.filter((m: Mission) => m.agentId === selected.id) : []
);
function describeMissionState(m: Mission): string {
return { active: 'aktiv', paused: 'pausiert', done: 'fertig', archived: 'archiviert' }[m.state];
}
// ── Policy editor ───────────────────────────────────────
// We expose a compact form of AiPolicy: the global default +
// per-module overrides for the handful of modules that matter.
// Per-tool overrides are a power-user knob that can come later.
const POLICY_MODULES = ['todo', 'calendar', 'notes', 'kontext', 'finance', 'drink', 'food'];
const POLICY_CHOICES: PolicyDecision[] = ['auto', 'propose', 'deny'];
const POLICY_LABEL: Record<PolicyDecision, string> = {
@ -142,6 +151,58 @@
propose: 'Vorschlag',
deny: 'Verboten',
};
let policyAdvanced = $state(false);
/**
* Generate a natural-language summary of the current policy.
* Reads the agent's policy and produces a short German sentence.
*/
function describePolicyNatural(policy: AiPolicy): string {
const parts: string[] = [];
const autoTools: string[] = [];
const proposeTools: string[] = [];
const denyTools: string[] = [];
for (const [name, decision] of Object.entries(policy.tools)) {
if (decision === 'auto') autoTools.push(name);
else if (decision === 'deny') denyTools.push(name);
}
// Module overrides
const moduleOverrides = Object.entries(policy.defaultsByModule ?? {});
for (const [mod, decision] of moduleOverrides) {
if (decision === 'auto') parts.push(`${mod}: automatisch`);
else if (decision === 'deny') parts.push(`${mod}: gesperrt`);
}
const defaultLabel =
policy.defaultForAi === 'auto'
? 'automatisch'
: policy.defaultForAi === 'deny'
? 'gesperrt'
: 'Vorschlag';
const lines: string[] = [];
if (denyTools.length > 0) {
lines.push(
`Gesperrt: ${denyTools.slice(0, 5).join(', ')}${denyTools.length > 5 ? ' …' : ''}`
);
}
if (parts.length > 0) {
lines.push(parts.join(' · '));
}
lines.push(`Alles andere: ${defaultLabel}`);
return lines.join('\n');
}
/** Determine which template preset most closely matches the current
* policy — used to pre-select the simple-mode radio. */
function currentPolicyPreset(policy: AiPolicy): string {
if (policy.defaultForAi === 'deny') return 'cautious';
const hasAutoModules = Object.values(policy.defaultsByModule ?? {}).some((v) => v === 'auto');
if (hasAutoModules) return 'aggressive';
return 'standard';
}
async function setDefaultForAi(agent: Agent, value: PolicyDecision) {
await updateAgent(agent.id, {
@ -403,24 +464,66 @@
</button>
</div>
<!-- ── Missions for this Agent ──────────────────── -->
<section class="block">
<h3><Flag size={12} /> Missions ({agentMissions.length})</h3>
{#if agentMissions.length === 0}
<p class="hint">Dieser Agent hat noch keine Missions.</p>
{:else}
<ul class="mission-list">
{#each agentMissions as m (m.id)}
<li class="mission-item">
<span class="dot dot-{m.state}"></span>
<span class="mission-title-text">{m.title}</span>
<span class="mission-state">{describeMissionState(m)}</span>
</li>
{/each}
</ul>
{/if}
<button
type="button"
class="secondary mission-new-btn"
onclick={() => goto(`/agents/templates`)}
>
<Plus size={12} /><span>Neue Mission für {selected.name}</span>
</button>
</section>
<!-- ── Policy ─────────────────────────────────── -->
<section class="block">
<h3>Policy</h3>
<p class="hint">
Entscheidet pro Modul was der Agent autonom darf. Tool-spezifische Feinheiten kommen später.
</p>
<div class="policy-row">
<span class="lbl">Template übernehmen</span>
<select onchange={(e) => applyTemplate(selected, (e.target as HTMLSelectElement).value)}>
<option value=""></option>
<!-- Natural language summary -->
<pre class="policy-natural">{describePolicyNatural(selected.policy)}</pre>
<!-- Simple mode: 3 radio presets -->
<div class="policy-simple">
{#each TEMPLATES as t (t.key)}
<option value={t.key}>{t.label}</option>
<label class="radio-card" class:active={currentPolicyPreset(selected.policy) === t.key}>
<input
type="radio"
name="policyPreset"
value={t.key}
checked={currentPolicyPreset(selected.policy) === t.key}
onchange={() => applyTemplate(selected, t.key)}
/>
<span class="radio-card-label">{t.label}</span>
</label>
{/each}
</select>
</div>
<!-- Advanced toggle -->
<button
type="button"
class="toggle-advanced"
onclick={() => (policyAdvanced = !policyAdvanced)}
>
{policyAdvanced ? '▾ Erweitert ausblenden' : '▸ Erweitert anzeigen'}
</button>
{#if policyAdvanced}
<div class="policy-row">
<span class="lbl">Global: wenn kein Modul passt</span>
<span class="lbl">Global-Default</span>
<div class="radio-group">
{#each POLICY_CHOICES as c}
<label class="radio">
@ -470,6 +573,7 @@
{/each}
</tbody>
</table>
{/if}
</section>
</div>
{/if}
@ -777,4 +881,92 @@
.policy-table code {
font-size: 0.75rem;
}
/* ── Missions section ─── */
.mission-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.mission-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
font-size: 0.8125rem;
}
.mission-title-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mission-state {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
}
.mission-new-btn {
margin-top: 0.375rem;
}
/* ── Policy natural language ─── */
.policy-natural {
margin: 0;
padding: 0.5rem 0.75rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
font: inherit;
font-size: 0.8125rem;
line-height: 1.5;
white-space: pre-wrap;
color: hsl(var(--color-muted-foreground));
}
/* ── Policy simple mode ─── */
.policy-simple {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.radio-card {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
font-size: 0.8125rem;
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s;
}
.radio-card.active {
border-color: hsl(var(--color-primary));
background: color-mix(in oklab, hsl(var(--color-primary)) 8%, transparent);
}
.radio-card input[type='radio'] {
margin: 0;
}
.radio-card-label {
white-space: nowrap;
}
.toggle-advanced {
align-self: flex-start;
border: none;
background: none;
padding: 0.25rem 0;
font: inherit;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
}
.toggle-advanced:hover {
color: hsl(var(--color-foreground));
}
</style>

View file

@ -12,6 +12,7 @@
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
import { PencilSimple, Trash, PushPin } from '@mana/shared-icons';
import FloatingInputBar from '$lib/components/FloatingInputBar.svelte';
import AgentDot from '$lib/components/ai/AgentDot.svelte';
let { navigate, goBack, params }: ViewProps = $props();
@ -188,6 +189,7 @@
<div class="note-content">
<div class="note-top">
<span class="note-title">{note.title || 'Unbenannt'}</span>
<AgentDot record={note} />
{#if note.isPinned}<span class="pin">&#x1f4cc;</span>{/if}
</div>
{#if note.content}

View file

@ -13,6 +13,7 @@
import { dropTarget, dragSource } from '@mana/shared-ui/dnd';
import type { TagDragData } from '@mana/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import AgentDot from '$lib/components/ai/AgentDot.svelte';
import { addTagId } from '$lib/data/tag-mutations';
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
import FloatingInputBar from '$lib/components/FloatingInputBar.svelte';
@ -171,6 +172,7 @@
})}
>
<span class="task-title" class:completed={task.isCompleted}>{task.title}</span>
<AgentDot record={task} />
</button>
<div class="task-right">
{#each taskTags as tag (tag.id)}