feat(ai-agents): Agents UI + Scene binding + Mission picker (Phase 5)

The Multi-Agent Workbench becomes visible. User can now create named
agents with their own persona (name, avatar, role), policy, memory,
and budgets; missions are created against a specific agent; scenes
can be bound to an agent so the Workbench tab shows who's at home.

New UI:
- /ai-agents module (ListView with list/create/detail modes, mirroring
  the ai-missions pattern). Detail view exposes:
  * Profile (name, avatar emoji, role) + pause/resume/archive/delete
  * Behavior (systemPrompt + memory — both encrypted at rest per the
    registry)
  * Limits (maxConcurrentMissions, maxTokensPerDay)
  * Policy editor: global default (auto/propose/deny) + per-module
    overrides for the usual suspects (todo / calendar / notes / …).
    Three quick-apply templates: Standard, Cautious, Aggressive.
  * Clear save path with duplicate-name handling from the store.
- Registered under the 'ai' category alongside the existing AI apps.

New component: AgentPicker — compact <select> over active agents plus
an optional "— keiner —" slot. Reused in:
- ai-missions create flow (new "Agent" fieldset — who executes this
  mission; passes agentId through to createMission so the very first
  server-iteration writes land under the right principal).
- BindAgentDialog — scene context-menu entry "An Agent binden…"
  opens this dialog. Purely a UI lens: scene.viewingAsAgentId ends up
  set, SceneAppBar now renders the agent's avatar on the scene tab
  next to the name (tooltip on hover). No data-scope change.

Store updates:
- WorkbenchScene gains viewingAsAgentId?; patchScene accepts it;
  workbenchScenesStore.setSceneAgent(id, agentId|undefined) helper.
- CreateMissionInput gains agentId?; the ai-missions create form
  passes it. MissionPatch also extended so future editors can reassign
  a mission to a different agent.

Tests: webapp svelte-check 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 21:56:02 +02:00
parent cacbfb0764
commit 51e6a20daf
11 changed files with 984 additions and 2 deletions

View file

@ -832,6 +832,16 @@ registerApp({
],
});
registerApp({
id: 'news-research',
name: 'News Research',
color: '#0891B2',
icon: Binoculars,
views: {
list: { load: () => import('$lib/modules/news-research/ListView.svelte') },
},
});
registerApp({
id: 'firsts',
name: 'Firsts',
@ -1006,6 +1016,16 @@ registerApp({
},
});
registerApp({
id: 'ai-agents',
name: 'AI Agents',
color: '#8B5CF6',
icon: Flag,
views: {
list: { load: () => import('$lib/modules/ai-agents/ListView.svelte') },
},
});
registerApp({
id: 'ai-workbench',
name: 'AI Workbench',

View file

@ -45,6 +45,7 @@ export const APP_CATEGORY_MAP: Record<string, AppCategory> = {
// AI Workbench — the 6 new AI feature apps + companion chat
companion: 'ai',
'ai-missions': 'ai',
'ai-agents': 'ai',
'ai-workbench': 'ai',
'ai-rituals': 'ai',
'ai-policy': 'ai',

View file

@ -0,0 +1,68 @@
<!--
AgentPicker — compact <select> over the user's active agents plus
an "(unset)" option for missions that shouldn't be bound to any
specific agent. Used by the mission create/edit flow and the scene
settings dialog.
-->
<script lang="ts">
import { useAgents } from '$lib/data/ai/agents/queries';
interface Props {
/** Current agent id (or undefined for unset / default fallback). */
value: string | undefined;
/** Fired when the user picks a different agent. Null = cleared. */
onSelect: (id: string | undefined) => void;
/** When true, the "(keiner)" option is omitted — every mission
* must pick an agent. Default false. */
required?: boolean;
/** Render just the select (default) or a labeled row. */
label?: string;
}
let { value, onSelect, required = false, label }: Props = $props();
const agents = $derived(useAgents({ state: 'active' }));
function handleChange(e: Event) {
const v = (e.target as HTMLSelectElement).value;
onSelect(v === '' ? undefined : v);
}
</script>
<div class="wrap">
{#if label}
<span class="lbl">{label}</span>
{/if}
<select value={value ?? ''} onchange={handleChange}>
{#if !required}
<option value="">— keiner —</option>
{/if}
{#each agents.value as a (a.id)}
<option value={a.id}>{a.avatar ?? '🤖'} {a.name}</option>
{/each}
</select>
</div>
<style>
.wrap {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.lbl {
font-size: 0.6875rem;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.04em;
color: hsl(var(--color-muted-foreground));
}
select {
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
font: inherit;
font-size: 0.8125rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
}
</style>

View file

@ -7,6 +7,13 @@
import { tick } from 'svelte';
import type { CarouselPage } from '$lib/components/page-carousel/types';
import type { WorkbenchScene } from '$lib/types/workbench-scenes';
import { useAgents } from '$lib/data/ai/agents/queries';
// Resolve each scene's bound agent → avatar + name. Cheap lookup
// since all active agents are already in memory from the live-
// query. No extra Dexie round-trip per render.
const agents = $derived(useAgents({ state: 'active' }));
const agentById = $derived(new Map(agents.value.map((a) => [a.id, a])));
interface Props {
scenes: WorkbenchScene[];
@ -74,6 +81,7 @@
{@const isActive = scene.id === activeSceneId}
{#if isActive && pages.length > 0}
{@const boundAgent = scene.viewingAsAgentId ? agentById.get(scene.viewingAsAgentId) : null}
<!-- Active scene + its app tabs wrapped in a visual group -->
<div class="scene-group">
<button
@ -81,7 +89,11 @@
class="scene-pill active"
onclick={() => onSceneSelect(scene.id)}
oncontextmenu={(e) => onSceneContextMenu(e, scene)}
title={boundAgent ? `Agent: ${boundAgent.name}` : undefined}
>
{#if boundAgent}
<span class="scene-agent-avatar">{boundAgent.avatar ?? '🤖'}</span>
{/if}
<span class="scene-name">{scene.name}</span>
<span class="scene-count">{scene.openApps.length}</span>
</button>
@ -108,13 +120,18 @@
</button>
</div>
{:else}
{@const boundAgent = scene.viewingAsAgentId ? agentById.get(scene.viewingAsAgentId) : null}
<button
type="button"
class="scene-pill"
class:active={isActive}
onclick={() => onSceneSelect(scene.id)}
oncontextmenu={(e) => onSceneContextMenu(e, scene)}
title={boundAgent ? `Agent: ${boundAgent.name}` : undefined}
>
{#if boundAgent}
<span class="scene-agent-avatar">{boundAgent.avatar ?? '🤖'}</span>
{/if}
<span class="scene-name">{scene.name}</span>
<span class="scene-count">{scene.openApps.length}</span>
</button>
@ -219,6 +236,10 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.scene-agent-avatar {
font-size: 0.875rem;
line-height: 1;
}
.scene-count {
font-size: 0.9375rem;
font-weight: 500;

View file

@ -0,0 +1,123 @@
<!--
BindAgentDialog — small modal for picking (or clearing) the Agent a
Scene is "viewed as". Purely a UI lens; see
docs/plans/multi-agent-workbench.md §Phase 5d for the rationale.
-->
<script lang="ts">
import AgentPicker from '$lib/components/ai/AgentPicker.svelte';
import { workbenchScenesStore } from '$lib/stores/workbench-scenes.svelte';
import type { WorkbenchScene } from '$lib/types/workbench-scenes';
interface Props {
scene: WorkbenchScene | null;
open: boolean;
}
let { scene = $bindable(null), open = $bindable(false) }: Props = $props();
let selection = $state<string | undefined>(undefined);
$effect(() => {
if (scene) selection = scene.viewingAsAgentId;
});
async function save() {
if (!scene) return;
await workbenchScenesStore.setSceneAgent(scene.id, selection);
open = false;
}
function cancel() {
open = false;
}
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') cancel();
}
</script>
<svelte:window onkeydown={handleKey} />
{#if open && scene}
<div class="scrim-wrap" role="presentation">
<button type="button" class="scrim" aria-label="Schließen" onclick={cancel}></button>
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="bind-agent-title">
<h2 id="bind-agent-title">Scene an Agent binden</h2>
<p class="lede">
Die Scene „{scene.name}" zeigt ab dann den Agent-Avatar als Hinweis und setzt ihn in
Mission-Filtern vor. Scene-Inhalte ändern sich dadurch nicht.
</p>
<AgentPicker value={selection} onSelect={(id) => (selection = id)} label="Agent" />
<footer>
<button type="button" class="btn-ghost" onclick={cancel}>Abbrechen</button>
<button type="button" class="btn-primary" onclick={save}>Übernehmen</button>
</footer>
</div>
</div>
{/if}
<style>
.scrim-wrap {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.scrim {
position: absolute;
inset: 0;
border: none;
background: rgba(0, 0, 0, 0.45);
cursor: pointer;
}
.panel {
position: relative;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
padding: 1.25rem 1.5rem;
max-width: 24rem;
width: 100%;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
h2 {
margin: 0;
font-size: 1rem;
}
.lede {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.4;
}
footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.btn-ghost,
.btn-primary {
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
font: inherit;
font-size: 0.8125rem;
cursor: pointer;
}
.btn-ghost {
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
}
.btn-primary {
border: 1px solid color-mix(in oklab, hsl(var(--color-primary)) 45%, transparent);
background: color-mix(in oklab, hsl(var(--color-primary)) 18%, hsl(var(--color-surface)));
color: hsl(var(--color-primary));
}
</style>

View file

@ -47,6 +47,11 @@ export interface CreateMissionInput {
objective: string;
inputs?: MissionInputRef[];
cadence: MissionCadence;
/** Owning agent id. Optional when omitted, the mission inherits
* the legacy default agent via the bootstrap migration. Pass an id
* explicitly from the create UI so new missions land on the right
* agent from the first write. */
agentId?: string;
}
export async function createMission(input: CreateMissionInput): Promise<Mission> {
@ -69,6 +74,7 @@ export async function createMission(input: CreateMissionInput): Promise<Mission>
state: 'active',
nextRunAt: nextRunForCadence(cadencePlain, new Date()),
iterations: [],
agentId: input.agentId,
};
await table().add(mission);
return mission;
@ -107,6 +113,7 @@ export interface MissionPatch {
objective?: string;
inputs?: MissionInputRef[];
cadence?: MissionCadence;
agentId?: string;
}
export async function updateMission(id: string, patch: MissionPatch): Promise<void> {

View file

@ -0,0 +1,693 @@
<!--
AI Agents app — workbench card.
Master-detail inline (list ↔ create ↔ detail) in a single panel,
mirroring the ai-missions module. Detail view exposes:
- role + name rename
- avatar (emoji)
- system-prompt + memory (both are encrypted at rest via the
crypto registry)
- policy editor (coarse: defaultForAi + a few per-module overrides)
- budget + concurrency
- archive / delete
Policy is intentionally exposed in a coarse form for v1 — per-tool
overrides are powerful but noisy. The defaultForAi radio gives users
a one-click "careful vs aggressive" switch; per-module overrides
handle the common "let the agent touch todo but not calendar" case.
-->
<script lang="ts">
import { ArrowLeft, Plus, Pause, Play, Archive, Trash } from '@mana/shared-icons';
import { useAgents } from '$lib/data/ai/agents/queries';
import {
createAgent,
updateAgent,
archiveAgent,
pauseAgent,
resumeAgent,
deleteAgent,
DuplicateAgentNameError,
} from '$lib/data/ai/agents/store';
import { DEFAULT_AI_POLICY } from '$lib/data/ai/policy';
import type { Agent } from '$lib/data/ai/agents/types';
import type { AiPolicy, PolicyDecision } from '@mana/shared-ai';
const agents = $derived(useAgents());
let mode = $state<'list' | 'create' | 'detail'>('list');
let selectedId = $state<string | null>(null);
const selected = $derived<Agent | null>(
selectedId ? (agents.value.find((a) => a.id === selectedId) ?? null) : null
);
// ── Create form ─────────────────────────────────────────
let formName = $state('');
let formAvatar = $state('🤖');
let formRole = $state('');
let formError = $state<string | null>(null);
let creating = $state(false);
async function handleCreate() {
if (!formName.trim() || !formRole.trim()) return;
formError = null;
creating = true;
try {
const a = await createAgent({
name: formName.trim(),
avatar: formAvatar || undefined,
role: formRole.trim(),
});
formName = '';
formAvatar = '🤖';
formRole = '';
selectedId = a.id;
mode = 'detail';
} catch (err) {
if (err instanceof DuplicateAgentNameError) {
formError = `Agent-Name „${err.name}" ist bereits vergeben.`;
} else {
formError = err instanceof Error ? err.message : String(err);
}
} finally {
creating = false;
}
}
// ── Detail edits ────────────────────────────────────────
let editName = $state('');
let editAvatar = $state('');
let editRole = $state('');
let editSystemPrompt = $state('');
let editMemory = $state('');
let editMaxConcurrent = $state(1);
let editMaxTokensPerDay = $state<number | null>(null);
let lastSyncedId = $state<string | null>(null);
let saveError = $state<string | null>(null);
let saving = $state(false);
$effect(() => {
if (selected && selected.id !== lastSyncedId) {
editName = selected.name;
editAvatar = selected.avatar ?? '';
editRole = selected.role;
editSystemPrompt = selected.systemPrompt ?? '';
editMemory = selected.memory ?? '';
editMaxConcurrent = selected.maxConcurrentMissions;
editMaxTokensPerDay = selected.maxTokensPerDay ?? null;
lastSyncedId = selected.id;
saveError = null;
}
});
async function handleSave(agent: Agent) {
saveError = null;
saving = true;
try {
await updateAgent(agent.id, {
name: editName.trim() !== agent.name ? editName.trim() : undefined,
avatar: editAvatar || undefined,
role: editRole.trim(),
systemPrompt: editSystemPrompt || undefined,
memory: editMemory || undefined,
maxConcurrentMissions: editMaxConcurrent,
maxTokensPerDay: editMaxTokensPerDay ?? undefined,
});
} catch (err) {
if (err instanceof DuplicateAgentNameError) {
saveError = `Agent-Name „${err.name}" ist bereits vergeben.`;
} else {
saveError = err instanceof Error ? err.message : String(err);
}
} finally {
saving = false;
}
}
// ── 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> = {
auto: 'Automatisch',
propose: 'Vorschlag',
deny: 'Verboten',
};
async function setDefaultForAi(agent: Agent, value: PolicyDecision) {
await updateAgent(agent.id, {
policy: { ...agent.policy, defaultForAi: value },
});
}
async function setModuleDefault(agent: Agent, moduleName: string, value: PolicyDecision) {
const current = agent.policy.defaultsByModule ?? {};
await updateAgent(agent.id, {
policy: {
...agent.policy,
defaultsByModule: { ...current, [moduleName]: value },
},
});
}
async function clearModuleDefault(agent: Agent, moduleName: string) {
const current = { ...(agent.policy.defaultsByModule ?? {}) };
delete current[moduleName];
await updateAgent(agent.id, {
policy: { ...agent.policy, defaultsByModule: current },
});
}
function moduleDecisionOrDefault(policy: AiPolicy, moduleName: string): PolicyDecision | '' {
return (policy.defaultsByModule?.[moduleName] ?? '') as PolicyDecision | '';
}
// ── Templates ───────────────────────────────────────────
const TEMPLATES: Array<{ key: string; label: string; policy: AiPolicy }> = [
{
key: 'standard',
label: 'Standard (Vorschlag für alles)',
policy: DEFAULT_AI_POLICY,
},
{
key: 'cautious',
label: 'Vorsichtig (alles Vorschlag, Schreiben verboten)',
policy: {
...DEFAULT_AI_POLICY,
tools: Object.fromEntries(
Object.entries(DEFAULT_AI_POLICY.tools).map(([k, v]) => [
k,
v === 'auto' ? 'auto' : ('propose' as PolicyDecision),
])
),
defaultForAi: 'propose',
},
},
{
key: 'aggressive',
label: 'Aggressiv (gleichartige Schreibvorgänge automatisch)',
policy: {
...DEFAULT_AI_POLICY,
defaultsByModule: { drink: 'auto', food: 'auto' },
},
},
];
async function applyTemplate(agent: Agent, key: string) {
const t = TEMPLATES.find((x) => x.key === key);
if (!t) return;
await updateAgent(agent.id, { policy: t.policy });
}
// ── Lifecycle helpers ───────────────────────────────────
function openDetail(id: string) {
selectedId = id;
mode = 'detail';
}
async function handleDelete(agent: Agent) {
if (!confirm(`Agent „${agent.name}" löschen? Missionen laufen orphan weiter.`)) return;
await deleteAgent(agent.id);
mode = 'list';
selectedId = null;
}
</script>
{#if mode === 'list'}
<div class="pane">
<header class="bar">
<button type="button" class="primary" onclick={() => (mode = 'create')}>
<Plus size={14} /><span>Neuer Agent</span>
</button>
</header>
{#if agents.value.length === 0}
<p class="empty">
Noch keine Agenten. Ein Default-Agent „Mana" wird beim ersten Login automatisch angelegt;
für weitere persona-basierte Agenten klicke auf „Neuer Agent".
</p>
{:else}
<ul class="m-list">
{#each agents.value as a (a.id)}
<li>
<button type="button" class="m-item" onclick={() => openDetail(a.id)}>
<span class="m-title">
<span class="avatar">{a.avatar ?? '🤖'}</span>
<span class="m-name">{a.name}</span>
<span class="dot dot-{a.state}" title={a.state}></span>
</span>
<span class="m-meta">
<span>{a.role}</span>
</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{:else if mode === 'create'}
<form class="create" onsubmit={(e) => (e.preventDefault(), handleCreate())}>
<button type="button" class="back-btn" onclick={() => (mode = 'list')}>
<ArrowLeft size={14} /><span>Abbrechen</span>
</button>
<label>
<span class="lbl">Name</span>
<input bind:value={formName} placeholder="z.B. Travel Planner" required />
</label>
<label>
<span class="lbl">Avatar (Emoji)</span>
<input bind:value={formAvatar} maxlength="4" />
</label>
<label>
<span class="lbl">Rolle / Aufgabe</span>
<input bind:value={formRole} placeholder="Was macht dieser Agent für dich?" required />
</label>
{#if formError}
<p class="form-error">{formError}</p>
{/if}
<div class="form-actions">
<button type="submit" class="primary" disabled={creating}>
{creating ? 'Erstelle…' : 'Agent anlegen'}
</button>
</div>
</form>
{:else if selected}
<div class="detail">
<button type="button" class="back-btn" onclick={() => (mode = 'list')}>
<ArrowLeft size={14} /><span>Liste</span>
</button>
<h2 class="detail-title">
<span class="avatar">{selected.avatar ?? '🤖'}</span>
<span>{selected.name}</span>
<span class="state-pill state-{selected.state}">{selected.state}</span>
</h2>
<div class="detail-actions">
{#if selected.state === 'active'}
<button type="button" onclick={() => pauseAgent(selected.id)}>
<Pause size={12} /><span>Pause</span>
</button>
{:else if selected.state === 'paused'}
<button type="button" onclick={() => resumeAgent(selected.id)}>
<Play size={12} /><span>Fortsetzen</span>
</button>
{/if}
{#if selected.state !== 'archived'}
<button type="button" onclick={() => archiveAgent(selected.id)}>
<Archive size={12} /><span>Archivieren</span>
</button>
{/if}
<button type="button" class="danger" onclick={() => handleDelete(selected)}>
<Trash size={12} />
</button>
</div>
<section class="block">
<h3>Profil</h3>
<label>
<span class="lbl">Name</span>
<input bind:value={editName} />
</label>
<label>
<span class="lbl">Avatar (Emoji)</span>
<input bind:value={editAvatar} maxlength="4" />
</label>
<label>
<span class="lbl">Rolle</span>
<input bind:value={editRole} />
</label>
</section>
<section class="block">
<h3>Verhalten</h3>
<label>
<span class="lbl">System-Anweisung (verschlüsselt)</span>
<textarea
bind:value={editSystemPrompt}
rows="3"
placeholder="Prepends auf jeden Planner-Prompt dieses Agents."
></textarea>
</label>
<label>
<span class="lbl">Gedächtnis (verschlüsselt)</span>
<textarea
bind:value={editMemory}
rows="5"
placeholder="Was der Agent dauerhaft über dich wissen soll."
></textarea>
</label>
</section>
<section class="block">
<h3>Grenzen</h3>
<label class="inline-field">
<span class="lbl">Parallele Missionen</span>
<input type="number" min="1" max="10" bind:value={editMaxConcurrent} />
</label>
<label class="inline-field">
<span class="lbl">Token-Budget / Tag</span>
<input
type="number"
min="0"
bind:value={editMaxTokensPerDay}
placeholder="leer = unbegrenzt"
/>
</label>
</section>
<div class="save-row">
{#if saveError}
<span class="form-error">{saveError}</span>
{/if}
<button type="button" class="primary" disabled={saving} onclick={() => handleSave(selected)}>
{saving ? 'Speichere…' : 'Speichern'}
</button>
</div>
<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>
{#each TEMPLATES as t (t.key)}
<option value={t.key}>{t.label}</option>
{/each}
</select>
</div>
<div class="policy-row">
<span class="lbl">Global: wenn kein Modul passt</span>
<div class="radio-group">
{#each POLICY_CHOICES as c}
<label class="radio">
<input
type="radio"
name="defaultForAi"
value={c}
checked={selected.policy.defaultForAi === c}
onchange={() => setDefaultForAi(selected, c)}
/>
<span>{POLICY_LABEL[c]}</span>
</label>
{/each}
</div>
</div>
<table class="policy-table">
<thead>
<tr>
<th>Modul</th>
<th>Entscheidung</th>
<th></th>
</tr>
</thead>
<tbody>
{#each POLICY_MODULES as mod}
{@const current = moduleDecisionOrDefault(selected.policy, mod)}
<tr>
<td><code>{mod}</code></td>
<td>
<select
value={current}
onchange={(e) => {
const v = (e.target as HTMLSelectElement).value;
if (!v) clearModuleDefault(selected, mod);
else setModuleDefault(selected, mod, v as PolicyDecision);
}}
>
<option value="">Global-Default</option>
{#each POLICY_CHOICES as c}
<option value={c}>{POLICY_LABEL[c]}</option>
{/each}
</select>
</td>
<td></td>
</tr>
{/each}
</tbody>
</table>
</section>
</div>
{/if}
<style>
.pane,
.create,
.detail {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.75rem 1rem 1.5rem;
}
.bar {
display: flex;
justify-content: flex-end;
}
.primary {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid color-mix(in oklab, hsl(var(--color-primary)) 45%, transparent);
border-radius: 0.375rem;
background: color-mix(in oklab, hsl(var(--color-primary)) 12%, hsl(var(--color-surface)));
color: hsl(var(--color-primary));
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
}
.primary:disabled {
opacity: 0.5;
}
.empty {
padding: 1.5rem 1rem;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
text-align: center;
}
.m-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.m-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.625rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
text-align: left;
cursor: pointer;
width: 100%;
font: inherit;
color: hsl(var(--color-foreground));
}
.m-item:hover {
border-color: hsl(var(--color-primary));
}
.m-title {
display: inline-flex;
gap: 0.5rem;
align-items: center;
font-weight: 600;
font-size: 0.9375rem;
}
.m-name {
flex: 1;
}
.avatar {
display: inline-block;
font-size: 1rem;
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
}
.dot-active {
background: #22c55e;
}
.dot-paused {
background: #f59e0b;
}
.dot-archived {
background: #6b7280;
}
.m-meta {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.back-btn {
align-self: flex-start;
display: inline-flex;
gap: 0.25rem;
align-items: center;
padding: 0.25rem 0.5rem;
border: none;
background: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 0.8125rem;
}
.create label,
.block label {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.lbl {
font-size: 0.6875rem;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.04em;
color: hsl(var(--color-muted-foreground));
}
input,
textarea,
select {
padding: 0.375rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
font: inherit;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
}
textarea {
font-family: var(--font-mono, ui-monospace, monospace);
resize: vertical;
}
.form-actions {
display: flex;
justify-content: flex-end;
}
.form-error {
color: hsl(var(--color-error));
font-size: 0.8125rem;
}
.detail-title {
display: inline-flex;
gap: 0.5rem;
align-items: center;
margin: 0;
font-size: 1.125rem;
}
.state-pill {
margin-left: auto;
padding: 0.0625rem 0.375rem;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
}
.state-active {
background: #d7f7e3;
color: #1b7a3a;
}
.state-paused {
background: #fde7c8;
color: #8a4f00;
}
.state-archived {
background: hsl(var(--color-surface));
color: hsl(var(--color-muted-foreground));
border: 1px solid hsl(var(--color-border));
}
.detail-actions {
display: flex;
gap: 0.25rem;
}
.detail-actions button {
display: inline-flex;
gap: 0.25rem;
align-items: center;
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-surface));
font: inherit;
font-size: 0.75rem;
cursor: pointer;
}
.detail-actions .danger {
color: hsl(var(--color-error));
}
.block {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
}
.block h3 {
margin: 0;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: hsl(var(--color-muted-foreground));
}
.inline-field {
flex-direction: row !important;
gap: 0.5rem !important;
align-items: center;
}
.inline-field input {
width: 6rem;
}
.save-row {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.5rem;
}
.hint {
margin: 0;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.policy-row {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: space-between;
}
.radio-group {
display: inline-flex;
gap: 0.5rem;
}
.radio {
flex-direction: row !important;
gap: 0.25rem !important;
align-items: center;
font-size: 0.8125rem;
}
.policy-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.policy-table th,
.policy-table td {
text-align: left;
padding: 0.25rem 0.375rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.policy-table th {
font-weight: 600;
font-size: 0.6875rem;
text-transform: uppercase;
color: hsl(var(--color-muted-foreground));
}
.policy-table code {
font-size: 0.75rem;
}
</style>

View file

@ -21,6 +21,7 @@
import { productionDeps } from '$lib/data/ai/missions/setup';
import MissionInputPicker from '$lib/components/ai/MissionInputPicker.svelte';
import MissionGrantDialog from '$lib/components/ai/MissionGrantDialog.svelte';
import AgentPicker from '$lib/components/ai/AgentPicker.svelte';
import AiDebugBlock from '$lib/components/ai/AiDebugBlock.svelte';
import AiProposalInbox from '$lib/components/ai/AiProposalInbox.svelte';
import { isAiDebugEnabled, setAiDebugEnabled } from '$lib/data/ai/missions/debug';
@ -48,6 +49,7 @@
let formIntervalMin = $state(60);
let formDailyHour = $state(9);
let formInputs = $state<MissionInputRef[]>([]);
let formAgentId = $state<string | undefined>(undefined);
let creating = $state(false);
function buildCadence(): MissionCadence {
@ -75,11 +77,13 @@
conceptMarkdown: formConcept,
inputs: formInputs,
cadence: buildCadence(),
agentId: formAgentId,
});
formTitle = '';
formObjective = '';
formConcept = '';
formInputs = [];
formAgentId = undefined;
formCadenceKind = 'manual';
selectedId = m.id;
mode = 'detail';
@ -255,6 +259,14 @@
rows="5"
></textarea>
</label>
<fieldset>
<legend>Agent</legend>
<AgentPicker
value={formAgentId}
onSelect={(id) => (formAgentId = id)}
label="Wer führt aus"
/>
</fieldset>
<fieldset>
<legend>Inputs (Kontext für die KI)</legend>
<MissionInputPicker bind:value={formInputs} />

View file

@ -99,7 +99,10 @@ function pickActiveId(scenes: WorkbenchScene[], current: string | null): string
async function patchScene(
id: string,
patch: Partial<
Pick<LocalWorkbenchScene, 'name' | 'description' | 'openApps' | 'order' | 'wallpaper'>
Pick<
LocalWorkbenchScene,
'name' | 'description' | 'openApps' | 'order' | 'wallpaper' | 'viewingAsAgentId'
>
>
) {
// Strip Svelte 5 $state proxies — IndexedDB's structured clone can't serialize them.
@ -221,6 +224,12 @@ export const workbenchScenesStore = {
await patchScene(id, { description });
},
/** Bind the scene to an Agent (or clear the binding). Purely a UI
* lens does not affect which data the open apps can see. */
async setSceneAgent(id: string, agentId: string | undefined) {
await patchScene(id, { viewingAsAgentId: agentId });
},
async duplicateScene(id: string) {
const src = scenesState.find((s) => s.id === id);
if (!src) return;

View file

@ -32,6 +32,16 @@ export interface WorkbenchScene {
order: number;
/** Per-scene wallpaper override. When set, takes priority over globalSettings.wallpaper. */
wallpaper?: WallpaperConfig;
/**
* Optional Agent this scene is "viewed as" (Multi-Agent Workbench
* Phase 5). Pure UI lens does NOT restrict which data the open
* apps see. When set, the scene tab shows the agent's avatar, the
* Workbench timeline defaults to this agent's filter, and the
* mission-create flow pre-selects it. Undefined = neutral scene
* (no agent binding); user can pick one explicitly in scene
* settings. See docs/plans/multi-agent-workbench.md §Phase 5d.
*/
viewingAsAgentId?: string;
}
/** Dexie row shape (adds the BaseRecord audit fields stamped by hooks). */

View file

@ -4,6 +4,7 @@
import SceneAppBar from '$lib/components/workbench/SceneAppBar.svelte';
import SceneHeader from '$lib/components/workbench/scenes/SceneHeader.svelte';
import ConfirmDialog from '$lib/components/workbench/scenes/ConfirmDialog.svelte';
import BindAgentDialog from '$lib/components/workbench/scenes/BindAgentDialog.svelte';
import { PageCarousel, type CarouselPage } from '$lib/components/page-carousel';
import { getApp, getAppByDragType, isAppAccessible } from '$lib/app-registry';
import { onMount, onDestroy } from 'svelte';
@ -13,7 +14,7 @@
import { DragPreview } from '@mana/shared-ui/dnd';
import type { DragType } from '@mana/shared-ui/dnd';
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { Pencil, Copy, Trash, Image } from '@mana/shared-icons';
import { Pencil, Copy, Trash, Image, Sparkle } from '@mana/shared-icons';
import { goto } from '$app/navigation';
import { _, locale } from 'svelte-i18n';
import { buildContextMenuItems, createWorkbenchContextMenu } from '$lib/context-menu';
@ -273,6 +274,12 @@
icon: Copy,
action: () => handleDuplicateScene(scene.id),
},
{
id: 'bind-agent',
label: scene.viewingAsAgentId ? 'Agent-Bindung ändern…' : 'An Agent binden…',
icon: Sparkle,
action: () => handleRequestBindAgent(scene.id),
},
{
id: 'wallpaper',
label: 'Hintergrund ändern',
@ -295,6 +302,15 @@
// ── Scene CRUD dialogs ──────────────────────────────────
let sceneToDelete = $state<{ id: string; name: string } | null>(null);
let sceneToBindAgent = $state<WorkbenchScene | null>(null);
let bindAgentDialogOpen = $state(false);
function handleRequestBindAgent(id: string) {
const scene = scenes.find((s) => s.id === id);
if (!scene) return;
sceneToBindAgent = scene;
bindAgentDialogOpen = true;
}
function handleRequestRename(id: string) {
// Unified rename path: scroll the carousel to the scene header
@ -389,6 +405,8 @@
onConfirm={handleConfirmDeleteScene}
onCancel={() => (sceneToDelete = null)}
/>
<BindAgentDialog bind:scene={sceneToBindAgent} bind:open={bindAgentDialogOpen} />
</div>
<style>