feat(companion): refactor into PageCarousel — every AI feature is a page

Drops the split /companion/missions + /companion/workbench +
/companion/rituals sub-routes and rebuilds /companion around the
shared PageCarousel pattern (same one /todo uses). Every feature is
now a self-contained page the user opens/closes/reorders/resizes in
one unified surface.

New pages:
- **Home** (default) — compact stats + one-click shortcuts to every
  other page, with "offen" badge when already open
- **Chat** — conversation sidebar + active chat inline
- **Missions** — list ↔ create ↔ detail master-detail inside one pane
- **Workbench** — timeline grouped by iteration + Revert per bucket,
  Mission filter dropdown (replaces the old ?mission=… query-param)
- **Rituals** — migrated from /companion/rituals
- **Policy** — NEW: 3-way per-tool toggle (auto/propose/deny) with
  localStorage-backed overrides merged into DEFAULT_AI_POLICY live
- **Insights** — NEW: approval rate, 14-day bar chart of AI events,
  per-mission stats, top-5 recurring user-feedback strings. All from
  local Dexie liveQueries, no server calls.
- **Health** — NEW: foreground runner status, manual tick trigger,
  link out to status.mana.how for server-runner uptime

Plumbing:
- `stores/workbench-settings.svelte.ts` — persistent open-pages list
  in localStorage (id + widthPx + heightPx + maximized); open/close/
  resize/moveLeft/moveRight helpers
- `pages/page-meta.ts` — central registry (title, color, icon,
  description) consumed by home shortcuts + PagePicker
- `pages/PagePicker.svelte` — lists available (not-yet-open) pages
  with icon + description
- Old sub-routes deleted: /companion/{missions,workbench,rituals}/

Webapp tests still 71/71 green; svelte-check clean on the new pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 13:11:16 +02:00
parent ce944ef14f
commit 9686198a16
15 changed files with 2788 additions and 1628 deletions

View file

@ -0,0 +1,231 @@
<!--
Default "home" page of the Companion carousel. Shows a compact summary
of AI activity + one-click buttons to open each of the other pages
alongside.
-->
<script lang="ts">
import { PageShell } from '$lib/components/page-carousel';
import { COMPANION_PAGE_META, ALL_COMPANION_PAGE_IDS } from './page-meta';
import { companionWorkbenchSettings } from '../stores/workbench-settings.svelte';
import type { CompanionPageId } from '../stores/workbench-settings.svelte';
import { useAiProposals } from '$lib/data/ai/proposals/queries';
import { useMissions } from '$lib/data/ai/missions/queries';
interface Props {
widthPx: number;
maximized?: boolean;
onClose: () => void;
onMaximize: () => void;
onResize: (widthPx: number, heightPx?: number) => void;
onMoveLeft?: () => void;
onMoveRight?: () => void;
}
let {
widthPx,
maximized = false,
onClose,
onMaximize,
onResize,
onMoveLeft,
onMoveRight,
}: Props = $props();
const meta = COMPANION_PAGE_META.home;
const proposals = $derived(useAiProposals({ status: 'pending' }));
const missions = $derived(useMissions({ state: 'active' }));
function openOrFocus(id: CompanionPageId) {
companionWorkbenchSettings.openPage(id);
}
function isOpen(id: CompanionPageId): boolean {
return companionWorkbenchSettings.openPages.some((p) => p.id === id);
}
// Everything except home — home is always already the current page.
const shortcuts = $derived(
ALL_COMPANION_PAGE_IDS.filter((id) => id !== 'home').map((id) => COMPANION_PAGE_META[id])
);
</script>
<PageShell
{widthPx}
{maximized}
{onClose}
{onMaximize}
{onResize}
{onMoveLeft}
{onMoveRight}
title={meta.title}
color={meta.color}
icon={meta.icon}
>
<div class="home">
<section class="stats">
<div class="stat">
<span class="stat-value">{proposals.value.length}</span>
<span class="stat-label">wartende Vorschläge</span>
</div>
<div class="stat">
<span class="stat-value">{missions.value.length}</span>
<span class="stat-label">aktive Missions</span>
</div>
</section>
<section class="section">
<h3 class="section-title">Bereiche öffnen</h3>
<div class="shortcut-grid">
{#each shortcuts as s (s.id)}
{@const open = isOpen(s.id)}
<button
type="button"
class="shortcut"
class:open
onclick={() => openOrFocus(s.id)}
style="--accent: {s.color};"
>
<span class="shortcut-icon">
<s.icon size={20} weight="bold" />
</span>
<span class="shortcut-body">
<span class="shortcut-title">
{s.title}
{#if open}<span class="badge-open">offen</span>{/if}
</span>
<span class="shortcut-desc">{s.description}</span>
</span>
</button>
{/each}
</div>
</section>
<section class="section hint">
<p>
Jeder Bereich lebt als eigene Page in diesem Carousel. Du kannst mehrere gleichzeitig offen
haben, einzeln schließen oder per „×" in der Header-Leiste beenden. Das Layout merkt sich
deine Auswahl.
</p>
</section>
</div>
</PageShell>
<style>
.home {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1rem 1.25rem 1.5rem;
}
.stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.stat {
padding: 0.75rem 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
background: hsl(var(--color-surface));
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
color: hsl(var(--color-foreground));
font-variant-numeric: tabular-nums;
}
.stat-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: hsl(var(--color-muted-foreground));
margin: 0 0 0.5rem;
}
.shortcut-grid {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.shortcut {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.625rem;
align-items: start;
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;
font: inherit;
color: hsl(var(--color-foreground));
transition:
border-color 120ms ease,
background 120ms ease;
}
.shortcut:hover {
border-color: var(--accent, hsl(var(--color-primary)));
background: color-mix(
in oklab,
var(--accent, hsl(var(--color-primary))) 8%,
hsl(var(--color-surface))
);
}
.shortcut.open {
border-left: 3px solid var(--accent, hsl(var(--color-primary)));
}
.shortcut-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.375rem;
background: color-mix(in oklab, var(--accent, hsl(var(--color-primary))) 14%, transparent);
color: var(--accent, hsl(var(--color-primary)));
}
.shortcut-body {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.shortcut-title {
font-weight: 600;
font-size: 0.9375rem;
display: inline-flex;
gap: 0.375rem;
align-items: center;
}
.shortcut-desc {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.badge-open {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.125rem 0.375rem;
border-radius: 999px;
background: color-mix(in oklab, var(--accent, hsl(var(--color-primary))) 20%, transparent);
color: var(--accent, hsl(var(--color-primary)));
}
.hint p {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.45;
}
</style>

View file

@ -0,0 +1,224 @@
<!--
Chat page — conversation list + active chat. The conversation sidebar
lives inline in the pane; picking a conversation swaps the chat area.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { PageShell } from '$lib/components/page-carousel';
import { Plus, Trash, Robot } from '@mana/shared-icons';
import CompanionChat from '$lib/modules/companion/components/CompanionChat.svelte';
import { chatStore } from '$lib/modules/companion/stores/chat.svelte';
import { useConversations } from '$lib/modules/companion/queries';
import type { LocalConversation } from '$lib/modules/companion/types';
import { COMPANION_PAGE_META } from './page-meta';
interface Props {
widthPx: number;
maximized?: boolean;
onClose: () => void;
onMaximize: () => void;
onResize: (widthPx: number, heightPx?: number) => void;
onMoveLeft?: () => void;
onMoveRight?: () => void;
}
let {
widthPx,
maximized = false,
onClose,
onMaximize,
onResize,
onMoveLeft,
onMoveRight,
}: Props = $props();
const meta = COMPANION_PAGE_META.chat;
const conversations = useConversations();
let activeConversation = $state<LocalConversation | null>(null);
let showList = $state(true);
onMount(() => {
if (conversations.value.length > 0) {
activeConversation = conversations.value[0];
showList = false;
}
});
$effect(() => {
if (!activeConversation && conversations.value.length > 0) {
activeConversation = conversations.value[0];
}
});
async function handleNewConversation() {
const conv = await chatStore.createConversation();
activeConversation = conv;
showList = false;
}
async function handleDeleteConversation(id: string) {
await chatStore.deleteConversation(id);
if (activeConversation?.id === id) {
activeConversation = conversations.value.find((c) => c.id !== id) ?? null;
}
}
</script>
<PageShell
{widthPx}
{maximized}
{onClose}
{onMaximize}
{onResize}
{onMoveLeft}
{onMoveRight}
title={meta.title}
color={meta.color}
icon={meta.icon}
>
<div class="chat">
<div class="bar">
<button type="button" class="toggle" onclick={() => (showList = !showList)}>
{showList ? 'Zum Chat' : `Gespräche (${conversations.value.length})`}
</button>
<button type="button" class="new" onclick={handleNewConversation}>
<Plus size={14} /><span>Neu</span>
</button>
</div>
{#if showList}
<div class="list">
{#each conversations.value as c (c.id)}
<div class="conv" class:active={activeConversation?.id === c.id}>
<button
type="button"
class="conv-main"
onclick={() => {
activeConversation = c;
showList = false;
}}
>
{c.title}
</button>
<button
type="button"
class="conv-del"
onclick={() => handleDeleteConversation(c.id)}
title="Löschen"
>
<Trash size={11} />
</button>
</div>
{/each}
{#if conversations.value.length === 0}
<p class="empty">Noch keine Gespräche — klick auf „Neu".</p>
{/if}
</div>
{:else if activeConversation}
{#key activeConversation.id}
<CompanionChat conversation={activeConversation} />
{/key}
{:else}
<div class="empty-state">
<Robot size={36} weight="thin" />
<h3>Mana Companion</h3>
<button type="button" class="start" onclick={handleNewConversation}>Gespräch starten</button
>
</div>
{/if}
</div>
</PageShell>
<style>
.chat {
display: flex;
flex-direction: column;
height: 100%;
}
.bar {
display: flex;
gap: 0.5rem;
justify-content: space-between;
padding: 0.375rem 0.75rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.toggle,
.new {
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.25rem;
background: hsl(var(--color-surface));
cursor: pointer;
font: inherit;
font-size: 0.75rem;
}
.new {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.list {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.conv {
display: flex;
align-items: center;
border: 1px solid transparent;
border-radius: 0.375rem;
}
.conv:hover {
border-color: hsl(var(--color-border));
}
.conv.active {
background: color-mix(in oklab, hsl(var(--color-primary)) 8%, transparent);
}
.conv-main {
flex: 1;
text-align: left;
padding: 0.375rem 0.5rem;
border: none;
background: none;
cursor: pointer;
font: inherit;
font-size: 0.875rem;
color: hsl(var(--color-foreground));
}
.conv-del {
border: none;
background: none;
padding: 0.25rem;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
}
.empty {
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
padding: 1rem 0;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.625rem;
color: hsl(var(--color-muted-foreground));
}
.empty-state h3 {
margin: 0;
}
.start {
padding: 0.5rem 1rem;
border: 1px solid hsl(var(--color-primary));
border-radius: 0.375rem;
background: hsl(var(--color-primary));
color: white;
cursor: pointer;
}
</style>

View file

@ -0,0 +1,179 @@
<!--
Health page — runner status + manual trigger. Shows foreground tick
state directly; the server-side mana-ai status falls through to the
status.mana.how check link because cross-origin /metrics scraping
from the browser isn't worth the CORS dance.
-->
<script lang="ts">
import { PageShell } from '$lib/components/page-carousel';
import { COMPANION_PAGE_META } from './page-meta';
import { isMissionTickRunning } from '$lib/data/ai/missions/setup';
import { runDueMissions } from '$lib/data/ai/missions/runner';
import { productionDeps } from '$lib/data/ai/missions/setup';
interface Props {
widthPx: number;
maximized?: boolean;
onClose: () => void;
onMaximize: () => void;
onResize: (widthPx: number, heightPx?: number) => void;
onMoveLeft?: () => void;
onMoveRight?: () => void;
}
let {
widthPx,
maximized = false,
onClose,
onMaximize,
onResize,
onMoveLeft,
onMoveRight,
}: Props = $props();
const meta = COMPANION_PAGE_META.health;
let lastRunStats = $state<{ at: string; plansProduced: number; errors: number } | null>(null);
let manualRunning = $state(false);
async function triggerManual() {
manualRunning = true;
try {
const results = await runDueMissions(new Date(), productionDeps);
let plansProduced = 0;
let errors = 0;
for (const r of results) {
plansProduced += r.plannedSteps;
errors += r.failedSteps;
}
lastRunStats = {
at: new Date().toLocaleString('de-DE'),
plansProduced,
errors,
};
} catch (err) {
console.error('[health] manual tick failed:', err);
lastRunStats = {
at: new Date().toLocaleString('de-DE'),
plansProduced: 0,
errors: 1,
};
} finally {
manualRunning = false;
}
}
</script>
<PageShell
{widthPx}
{maximized}
{onClose}
{onMaximize}
{onResize}
{onMoveLeft}
{onMoveRight}
title={meta.title}
color={meta.color}
icon={meta.icon}
>
<div class="h">
<section>
<h3>Foreground-Runner (dieser Tab)</h3>
<dl>
<dt>Status</dt>
<dd>
{#if isMissionTickRunning()}
<span class="ok">● aktiv · 60 s Interval</span>
{:else}
<span class="ko">● nicht aktiv</span>
{/if}
</dd>
{#if lastRunStats}
<dt>Letzter Manual-Tick</dt>
<dd>
{lastRunStats.at} · {lastRunStats.plansProduced} Plans ·
{lastRunStats.errors} Fehler
</dd>
{/if}
</dl>
<button type="button" class="run" onclick={triggerManual} disabled={manualRunning}>
{manualRunning ? 'Läuft…' : '⚡ Manual Tick'}
</button>
</section>
<section>
<h3>Server-Runner (mana-ai)</h3>
<p class="desc">
Läuft unabhängig vom Browser. Status + Uptime werden von Prometheus gescrapet und auf dem
Status-Dashboard angezeigt.
</p>
<a class="external" href="https://status.mana.how" target="_blank" rel="noreferrer">
status.mana.how öffnen →
</a>
</section>
<section>
<h3>Datenlage (lokal)</h3>
<p class="desc">
Alles in diesem Carousel kommt aus IndexedDB. Kein Server-Call außer beim Planner selbst.
</p>
</section>
</div>
</PageShell>
<style>
.h {
padding: 0.75rem 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
section h3 {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: hsl(var(--color-muted-foreground));
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.75rem;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
dt {
color: hsl(var(--color-muted-foreground));
}
dd {
margin: 0;
}
.ok {
color: #1b7a3a;
}
.ko {
color: #8a1b1b;
}
.run {
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-surface));
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
}
.run:disabled {
opacity: 0.5;
}
.desc {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.45;
}
.external {
font-size: 0.8125rem;
color: hsl(var(--color-primary));
}
</style>

View file

@ -0,0 +1,314 @@
<!--
Insights page — local aggregation over `_events` and mission
iterations. No server calls; everything comes from Dexie liveQueries.
-->
<script lang="ts">
import { PageShell } from '$lib/components/page-carousel';
import { COMPANION_PAGE_META } from './page-meta';
import { useAiTimeline } from '$lib/data/ai/timeline/queries';
import { useMissions } from '$lib/data/ai/missions/queries';
interface Props {
widthPx: number;
maximized?: boolean;
onClose: () => void;
onMaximize: () => void;
onResize: (widthPx: number, heightPx?: number) => void;
onMoveLeft?: () => void;
onMoveRight?: () => void;
}
let {
widthPx,
maximized = false,
onClose,
onMaximize,
onResize,
onMoveLeft,
onMoveRight,
}: Props = $props();
const meta = COMPANION_PAGE_META.insights;
const events = $derived(useAiTimeline({ limit: 1000 }));
const missions = $derived(useMissions());
// Count events per day across the last 14 days.
const perDay = $derived.by(() => {
const now = Date.now();
const buckets: { day: string; count: number }[] = [];
for (let i = 13; i >= 0; i--) {
const d = new Date(now - i * 86_400_000);
buckets.push({ day: d.toISOString().slice(0, 10), count: 0 });
}
for (const e of events.value) {
const day = e.meta.timestamp.slice(0, 10);
const b = buckets.find((x) => x.day === day);
if (b) b.count++;
}
return buckets;
});
const maxPerDay = $derived(Math.max(1, ...perDay.map((b) => b.count)));
// Aggregate iteration outcomes per mission.
const missionStats = $derived.by(() =>
missions.value.map((m) => {
let approved = 0;
let rejected = 0;
let failed = 0;
let awaiting = 0;
for (const it of m.iterations) {
if (it.overallStatus === 'approved') approved++;
else if (it.overallStatus === 'rejected') rejected++;
else if (it.overallStatus === 'failed') failed++;
else if (it.overallStatus === 'awaiting-review') awaiting++;
}
return {
id: m.id,
title: m.title,
approved,
rejected,
failed,
awaiting,
total: m.iterations.length,
};
})
);
// Top recurring feedback strings from iteration.userFeedback.
const topFeedback = $derived.by(() => {
const freq = new Map<string, number>();
for (const m of missions.value) {
for (const it of m.iterations) {
if (!it.userFeedback) continue;
// Normalize: lowercase + trim. Short strings, no ML tokenizer.
const key = it.userFeedback.trim().toLowerCase().slice(0, 80);
freq.set(key, (freq.get(key) ?? 0) + 1);
}
}
return [...freq.entries()]
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([text, count]) => ({ text, count }));
});
// Simple approval rate: approved / (approved + rejected), ignoring
// awaiting/failed.
const approvalRate = $derived.by(() => {
let a = 0;
let r = 0;
for (const m of missionStats) {
a += m.approved;
r += m.rejected;
}
if (a + r === 0) return null;
return Math.round((a / (a + r)) * 100);
});
</script>
<PageShell
{widthPx}
{maximized}
{onClose}
{onMaximize}
{onResize}
{onMoveLeft}
{onMoveRight}
title={meta.title}
color={meta.color}
icon={meta.icon}
>
<div class="ins">
<section>
<h3>Approval-Rate</h3>
{#if approvalRate === null}
<p class="empty">Noch nicht genug Daten.</p>
{:else}
<div class="big">{approvalRate}%</div>
<p class="sub">über alle Missions + alle Iterationen</p>
{/if}
</section>
<section>
<h3>AI-Events / Tag (14 Tage)</h3>
<div class="chart">
{#each perDay as b (b.day)}
<div class="bar" title={`${b.day}: ${b.count}`}>
<span
class="fill"
style="height: {(b.count / maxPerDay) * 100}%;"
class:has-value={b.count > 0}
></span>
</div>
{/each}
</div>
</section>
<section>
<h3>Pro Mission</h3>
{#if missionStats.length === 0}
<p class="empty">Keine Missions angelegt.</p>
{:else}
<ul class="m-stats">
{#each missionStats as m (m.id)}
<li>
<span class="m-title">{m.title}</span>
<span class="m-nums">
<span class="n ok">{m.approved}</span>
·
<span class="n ko">{m.rejected}</span>
·
<span class="n wait">{m.awaiting}</span>
·
<span class="n err">{m.failed}</span>
</span>
</li>
{/each}
</ul>
<p class="legend">
<span class="n ok"></span>approved ·
<span class="n ko"></span>rejected ·
<span class="n wait"></span>awaiting ·
<span class="n err"></span>failed
</p>
{/if}
</section>
<section>
<h3>Häufigstes Feedback</h3>
{#if topFeedback.length === 0}
<p class="empty">Noch keine Freitext-Reviews.</p>
{:else}
<ul class="fb-list">
{#each topFeedback as fb}
<li>
<span class="fb-count">{fb.count}×</span>
<span class="fb-text">"{fb.text}"</span>
</li>
{/each}
</ul>
{/if}
</section>
</div>
</PageShell>
<style>
.ins {
padding: 0.75rem 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
section h3 {
margin: 0 0 0.375rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: hsl(var(--color-muted-foreground));
}
.empty {
margin: 0;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
}
.big {
font-size: 2.25rem;
font-weight: 700;
color: hsl(var(--color-primary));
font-variant-numeric: tabular-nums;
}
.sub {
margin: 0;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.chart {
display: grid;
grid-template-columns: repeat(14, 1fr);
gap: 0.125rem;
height: 64px;
align-items: end;
}
.bar {
display: flex;
align-items: end;
justify-content: center;
height: 100%;
}
.fill {
display: inline-block;
width: 100%;
background: hsl(var(--color-muted));
border-radius: 0.125rem;
min-height: 2px;
}
.fill.has-value {
background: hsl(var(--color-primary));
}
.m-stats {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.m-stats li {
display: flex;
justify-content: space-between;
padding: 0.375rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
font-size: 0.8125rem;
}
.m-title {
font-weight: 600;
}
.m-nums {
font-variant-numeric: tabular-nums;
color: hsl(var(--color-muted-foreground));
}
.n {
font-weight: 600;
}
.n.ok {
color: #1b7a3a;
}
.n.ko {
color: #8a1b1b;
}
.n.wait {
color: #8a5a00;
}
.n.err {
color: #8a1b1b;
opacity: 0.7;
}
.legend {
margin: 0.25rem 0 0;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.fb-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.fb-list li {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem;
font-size: 0.8125rem;
}
.fb-count {
font-weight: 700;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-primary));
}
.fb-text {
font-style: italic;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,633 @@
<!--
Missions page — list ↔ detail in a single panel. Replaces the former
/companion/missions sub-route with a master-detail-inline view.
-->
<script lang="ts">
import { PageShell } from '$lib/components/page-carousel';
import { ArrowLeft, Play, Pause, Check, Trash, Plus } from '@mana/shared-icons';
import { COMPANION_PAGE_META } from './page-meta';
import { useMissions } from '$lib/data/ai/missions/queries';
import {
createMission,
pauseMission,
resumeMission,
completeMission,
deleteMission,
addIterationFeedback,
} from '$lib/data/ai/missions/store';
import { runMission } from '$lib/data/ai/missions/runner';
import { productionDeps } from '$lib/data/ai/missions/setup';
import MissionInputPicker from '$lib/components/ai/MissionInputPicker.svelte';
import type { Mission, MissionCadence, MissionInputRef } from '$lib/data/ai/missions/types';
interface Props {
widthPx: number;
maximized?: boolean;
onClose: () => void;
onMaximize: () => void;
onResize: (widthPx: number, heightPx?: number) => void;
onMoveLeft?: () => void;
onMoveRight?: () => void;
}
let {
widthPx,
maximized = false,
onClose,
onMaximize,
onResize,
onMoveLeft,
onMoveRight,
}: Props = $props();
const meta = COMPANION_PAGE_META.missions;
const missions = $derived(useMissions());
let mode = $state<'list' | 'create' | 'detail'>('list');
let selectedId = $state<string | null>(null);
const selected = $derived(
selectedId ? (missions.value.find((m) => m.id === selectedId) ?? null) : null
);
// ── Create form ────────────────────────────────────────
let formTitle = $state('');
let formObjective = $state('');
let formConcept = $state('');
let formCadenceKind = $state<MissionCadence['kind']>('manual');
let formIntervalMin = $state(60);
let formDailyHour = $state(9);
let formInputs = $state<MissionInputRef[]>([]);
let creating = $state(false);
function buildCadence(): MissionCadence {
switch (formCadenceKind) {
case 'manual':
return { kind: 'manual' };
case 'interval':
return { kind: 'interval', everyMinutes: formIntervalMin };
case 'daily':
return { kind: 'daily', atHour: formDailyHour, atMinute: 0 };
case 'weekly':
return { kind: 'weekly', dayOfWeek: 1, atHour: formDailyHour };
case 'cron':
return { kind: 'cron', expression: '' };
}
}
async function handleCreate() {
if (!formTitle.trim() || !formObjective.trim()) return;
creating = true;
try {
const m = await createMission({
title: formTitle.trim(),
objective: formObjective.trim(),
conceptMarkdown: formConcept,
inputs: formInputs,
cadence: buildCadence(),
});
formTitle = '';
formObjective = '';
formConcept = '';
formInputs = [];
formCadenceKind = 'manual';
selectedId = m.id;
mode = 'detail';
} finally {
creating = false;
}
}
// ── Detail actions ─────────────────────────────────────
let runningNow = $state(false);
async function handleRunNow(m: Mission) {
runningNow = true;
try {
await runMission(m.id, productionDeps);
} catch (err) {
console.error(err);
} finally {
runningNow = false;
}
}
let feedbackDraft = $state('');
async function handleFeedback(m: Mission, iterationId: string) {
if (!feedbackDraft.trim()) return;
await addIterationFeedback(m.id, iterationId, feedbackDraft.trim());
feedbackDraft = '';
}
function describeCadence(c: MissionCadence): string {
switch (c.kind) {
case 'manual':
return 'manuell';
case 'interval':
return `alle ${c.everyMinutes} min`;
case 'daily':
return `täglich ${String(c.atHour).padStart(2, '0')}:${String(c.atMinute).padStart(2, '0')}`;
case 'weekly':
return `wöchentlich (Tag ${c.dayOfWeek}, ${c.atHour}:00)`;
case 'cron':
return `cron: ${c.expression}`;
}
}
function describeState(s: Mission['state']): string {
return { active: 'aktiv', paused: 'pausiert', done: 'abgeschlossen', archived: 'archiviert' }[
s
];
}
function formatRelative(iso: string | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
const deltaMs = d.getTime() - Date.now();
const mins = Math.round(Math.abs(deltaMs) / 60_000);
const hours = Math.round(mins / 60);
const days = Math.round(hours / 24);
const value = mins < 60 ? `${mins}m` : hours < 24 ? `${hours}h` : `${days}d`;
return deltaMs < 0 ? `vor ${value}` : `in ${value}`;
}
function openDetail(id: string) {
selectedId = id;
mode = 'detail';
}
</script>
<PageShell
{widthPx}
{maximized}
{onClose}
{onMaximize}
{onResize}
{onMoveLeft}
{onMoveRight}
title={meta.title}
color={meta.color}
icon={meta.icon}
>
{#if mode === 'list'}
<div class="list-pane">
<header class="pane-header">
<button type="button" class="primary" onclick={() => (mode = 'create')}>
<Plus size={14} /><span>Neue Mission</span>
</button>
</header>
{#if missions.value.length === 0}
<p class="empty">
Keine Missions — lege eine an um die KI dauerhaft für dich arbeiten zu lassen.
</p>
{:else}
<ul class="m-list">
{#each missions.value as m (m.id)}
<li>
<button type="button" class="m-item" onclick={() => openDetail(m.id)}>
<span class="m-title">
<span class="dot dot-{m.state}"></span>
{m.title}
</span>
<span class="m-meta">
<span>{describeCadence(m.cadence)}</span>
<span>{m.iterations.length} Iter.</span>
{#if m.nextRunAt}
<span>next {formatRelative(m.nextRunAt)}</span>
{/if}
</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">Titel</span>
<input bind:value={formTitle} placeholder="z.B. Wöchentlicher Review" required />
</label>
<label>
<span class="lbl">Konkretes Ziel</span>
<input bind:value={formObjective} placeholder="Was soll die KI erreichen?" required />
</label>
<label>
<span class="lbl">Konzept (Markdown, optional)</span>
<textarea
bind:value={formConcept}
placeholder="# Rahmen&#10;Erkläre der KI Kontext, Regeln, Grenzen…"
rows="5"
></textarea>
</label>
<fieldset>
<legend>Inputs (Kontext für die KI)</legend>
<MissionInputPicker bind:value={formInputs} />
</fieldset>
<fieldset>
<legend>Cadence</legend>
<div class="cadence-row">
<label class="inline">
<input type="radio" bind:group={formCadenceKind} value="manual" /> Manuell
</label>
<label class="inline">
<input type="radio" bind:group={formCadenceKind} value="interval" /> Intervall
{#if formCadenceKind === 'interval'}
<input
class="inline-num"
type="number"
bind:value={formIntervalMin}
min="5"
max="1440"
/>
<span>min</span>
{/if}
</label>
<label class="inline">
<input type="radio" bind:group={formCadenceKind} value="daily" /> Täglich um
{#if formCadenceKind === 'daily'}
<input class="inline-num" type="number" bind:value={formDailyHour} min="0" max="23" />
:00
{/if}
</label>
</div>
</fieldset>
<div class="form-actions">
<button type="submit" class="primary" disabled={creating}>
{creating ? 'Erstelle…' : 'Mission 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">{selected.title}</h2>
<div class="detail-actions">
<button type="button" onclick={() => handleRunNow(selected)} disabled={runningNow}>
<Play size={12} /><span>{runningNow ? 'Läuft…' : 'Jetzt ausführen'}</span>
</button>
{#if selected.state === 'active'}
<button type="button" onclick={() => pauseMission(selected.id)}>
<Pause size={12} /><span>Pause</span>
</button>
{:else if selected.state === 'paused'}
<button type="button" onclick={() => resumeMission(selected.id)}>
<Play size={12} /><span>Fortsetzen</span>
</button>
{/if}
{#if selected.state !== 'done'}
<button type="button" onclick={() => completeMission(selected.id)}>
<Check size={12} /><span>Abschließen</span>
</button>
{/if}
<button
type="button"
class="danger"
onclick={() => {
if (confirm('Mission löschen?')) {
deleteMission(selected.id);
mode = 'list';
selectedId = null;
}
}}
>
<Trash size={12} />
</button>
</div>
<dl class="meta">
<dt>Ziel</dt>
<dd>{selected.objective}</dd>
<dt>Cadence</dt>
<dd>{describeCadence(selected.cadence)} · {describeState(selected.state)}</dd>
<dt>Next</dt>
<dd>{formatRelative(selected.nextRunAt)}</dd>
<dt>Inputs</dt>
<dd>
{#if selected.inputs.length === 0}{:else}
{selected.inputs.map((i) => `${i.module}/${i.id}`).join(', ')}
{/if}
</dd>
</dl>
{#if selected.conceptMarkdown}
<details>
<summary>Konzept</summary>
<pre>{selected.conceptMarkdown}</pre>
</details>
{/if}
<h3 class="section-title">Iterationen</h3>
{#if selected.iterations.length === 0}
<p class="empty">Noch keine Iteration gelaufen.</p>
{:else}
{#each [...selected.iterations].reverse() as it (it.id)}
<article class="it">
<header>
<span class="it-date">{new Date(it.startedAt).toLocaleString('de-DE')}</span>
<span class="badge badge-{it.overallStatus}">{it.overallStatus}</span>
</header>
{#if it.summary}<p class="it-summary">{it.summary}</p>{/if}
{#if it.userFeedback}
<blockquote class="fb">{it.userFeedback}</blockquote>
{:else if it.overallStatus === 'awaiting-review'}
<form
class="fb-form"
onsubmit={(e) => (e.preventDefault(), handleFeedback(selected, it.id))}
>
<textarea
bind:value={feedbackDraft}
placeholder="Feedback für die nächste Iteration…"
rows="2"
></textarea>
<button type="submit" disabled={!feedbackDraft.trim()}>Speichern</button>
</form>
{/if}
</article>
{/each}
{/if}
</div>
{/if}
</PageShell>
<style>
.pane-header {
display: flex;
justify-content: flex-end;
padding: 0.5rem 0.75rem;
}
.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;
cursor: not-allowed;
}
.empty {
padding: 1.5rem 1rem;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
text-align: center;
}
.list-pane {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.m-list {
list-style: none;
margin: 0;
padding: 0 0.75rem 1rem;
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.375rem;
align-items: center;
font-weight: 600;
font-size: 0.9375rem;
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
flex-shrink: 0;
}
.dot-active {
background: #22c55e;
}
.dot-paused {
background: #f59e0b;
}
.dot-done {
background: #6b7280;
}
.dot-archived {
background: #374151;
}
.m-meta {
display: flex;
gap: 0.75rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.create,
.detail {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.75rem 1rem 1.5rem;
}
.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;
}
.back-btn:hover {
color: hsl(var(--color-foreground));
}
.create label {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.lbl {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
}
.create input[type='text'],
.create input:not([type]),
.create textarea {
padding: 0.375rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
font: inherit;
}
.create textarea {
font-family: var(--font-mono, ui-monospace, monospace);
resize: vertical;
}
.create fieldset {
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
}
.cadence-row {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
}
.inline {
flex-direction: row !important;
align-items: center;
gap: 0.25rem !important;
}
.inline-num {
width: 4rem;
}
.form-actions {
display: flex;
justify-content: flex-end;
}
.detail-title {
margin: 0;
font-size: 1.125rem;
}
.detail-actions {
display: flex;
flex-wrap: wrap;
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 button.danger {
color: hsl(var(--color-error));
}
.meta {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.75rem;
font-size: 0.8125rem;
margin: 0;
}
.meta dt {
color: hsl(var(--color-muted-foreground));
}
.meta dd {
margin: 0;
}
details pre {
white-space: pre-wrap;
padding: 0.5rem;
background: hsl(var(--color-surface));
border-radius: 0.375rem;
font-size: 0.75rem;
margin: 0.375rem 0 0;
}
.section-title {
margin: 0.5rem 0 0;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: hsl(var(--color-muted-foreground));
}
.it {
padding: 0.625rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
}
.it header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.375rem;
}
.it-date {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.badge {
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
background: hsl(var(--color-surface));
text-transform: uppercase;
}
.badge-awaiting-review {
background: #fef0c9;
color: #8a5a00;
}
.badge-approved {
background: #d7f7e3;
color: #1b7a3a;
}
.badge-rejected,
.badge-failed {
background: #f7d7d7;
color: #8a1b1b;
}
.it-summary {
margin: 0 0 0.375rem;
font-size: 0.8125rem;
}
.fb {
margin: 0.25rem 0 0;
padding: 0.375rem 0.5rem;
border-left: 3px solid hsl(var(--color-border));
font-style: italic;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.fb-form {
display: flex;
gap: 0.375rem;
margin-top: 0.5rem;
}
.fb-form textarea {
flex: 1;
padding: 0.375rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
font: inherit;
}
.fb-form button {
align-self: flex-end;
padding: 0.375rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-surface));
cursor: pointer;
}
</style>

View file

@ -0,0 +1,138 @@
<!--
Picker for the Companion page carousel — shows every page that isn't
currently open and lets the user add it. Mirrors the PagePicker
pattern from the todo module.
-->
<script lang="ts">
import { ALL_COMPANION_PAGE_IDS, COMPANION_PAGE_META } from './page-meta';
import type { CompanionPageId } from '../stores/workbench-settings.svelte';
interface Props {
openIds: CompanionPageId[];
onPick: (id: CompanionPageId) => void;
onClose: () => void;
}
let { openIds, onPick, onClose }: Props = $props();
const availableIds = $derived(ALL_COMPANION_PAGE_IDS.filter((id) => !openIds.includes(id)));
</script>
<div class="picker">
<header class="head">
<span class="title">Page öffnen</span>
<button type="button" onclick={onClose} aria-label="Schließen">×</button>
</header>
{#if availableIds.length === 0}
<p class="empty">Alle Pages sind bereits geöffnet.</p>
{:else}
<ul>
{#each availableIds as id (id)}
{@const m = COMPANION_PAGE_META[id]}
<li>
<button type="button" class="row" onclick={() => onPick(id)} style="--accent: {m.color};">
<span class="icon"><m.icon size={16} weight="bold" /></span>
<span class="body">
<span class="t">{m.title}</span>
<span class="d">{m.description}</span>
</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.picker {
width: 320px;
padding: 0.5rem;
background: hsl(var(--color-card));
border: 2px solid hsl(0 0% 0% / 0.12);
border-radius: 1.25rem;
align-self: stretch;
min-height: 60vh;
display: flex;
flex-direction: column;
}
.head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0.5rem 0.75rem;
}
.title {
font-weight: 600;
font-size: 0.875rem;
}
.head button {
border: none;
background: none;
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
}
.empty {
padding: 1rem;
text-align: center;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
}
ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
overflow-y: auto;
}
.row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem;
align-items: start;
width: 100%;
padding: 0.5rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
text-align: left;
cursor: pointer;
font: inherit;
color: hsl(var(--color-foreground));
}
.row:hover {
border-color: var(--accent, hsl(var(--color-primary)));
background: color-mix(
in oklab,
var(--accent, hsl(var(--color-primary))) 8%,
hsl(var(--color-surface))
);
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.375rem;
background: color-mix(in oklab, var(--accent, hsl(var(--color-primary))) 14%, transparent);
color: var(--accent, hsl(var(--color-primary)));
}
.body {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.t {
font-weight: 600;
font-size: 0.875rem;
}
.d {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,271 @@
<!--
Policy page — lets the user override per-tool AI policy decisions
(auto / propose / deny). Overrides stored in localStorage and merged
into `getAiPolicy()` on the fly via `setAiPolicy`.
Note: the active tool registry (`getTools()`) only contains tools
that modules have registered so far in the current session. For the
editor to show every tool on first load, the tool-registration
modules need to have been initialized — which they are, via
`initTools()` in (app)/+layout.svelte.
-->
<script lang="ts">
import { PageShell } from '$lib/components/page-carousel';
import { COMPANION_PAGE_META } from './page-meta';
import { getTools } from '$lib/data/tools/registry';
import { DEFAULT_AI_POLICY, setAiPolicy, getAiPolicy } from '$lib/data/ai/policy';
import type { PolicyDecision, AiPolicy } from '$lib/data/ai/policy';
interface Props {
widthPx: number;
maximized?: boolean;
onClose: () => void;
onMaximize: () => void;
onResize: (widthPx: number, heightPx?: number) => void;
onMoveLeft?: () => void;
onMoveRight?: () => void;
}
let {
widthPx,
maximized = false,
onClose,
onMaximize,
onResize,
onMoveLeft,
onMoveRight,
}: Props = $props();
const meta = COMPANION_PAGE_META.policy;
const STORAGE_KEY = 'ai:policyOverrides';
function loadOverrides(): Record<string, PolicyDecision> {
if (typeof localStorage === 'undefined') return {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
return JSON.parse(raw) as Record<string, PolicyDecision>;
} catch {
return {};
}
}
function saveOverrides(overrides: Record<string, PolicyDecision>): void {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides));
} catch {
// ignore
}
}
function applyPolicy(overrides: Record<string, PolicyDecision>): void {
const nextPolicy: AiPolicy = {
...DEFAULT_AI_POLICY,
tools: { ...DEFAULT_AI_POLICY.tools, ...overrides },
};
setAiPolicy(nextPolicy);
}
let overrides = $state<Record<string, PolicyDecision>>(loadOverrides());
$effect(() => {
applyPolicy(overrides);
saveOverrides(overrides);
});
const tools = getTools();
type ToolEntry = (typeof tools)[number];
const grouped = $derived.by(() => {
const byModule = new Map<string, ToolEntry[]>();
for (const t of tools) {
if (!byModule.has(t.module)) byModule.set(t.module, []);
byModule.get(t.module)!.push(t);
}
return [...byModule.entries()].sort(([a], [b]) => a.localeCompare(b));
});
function decide(toolName: string): PolicyDecision {
if (overrides[toolName]) return overrides[toolName];
return getAiPolicy().tools[toolName] ?? getAiPolicy().defaultForAi;
}
function setDecision(toolName: string, d: PolicyDecision): void {
const dflt = DEFAULT_AI_POLICY.tools[toolName] ?? DEFAULT_AI_POLICY.defaultForAi;
if (d === dflt) {
const { [toolName]: _, ...rest } = overrides;
overrides = rest;
} else {
overrides = { ...overrides, [toolName]: d };
}
}
function resetAll() {
if (!confirm('Alle Policy-Überschreibungen zurücksetzen?')) return;
overrides = {};
}
const hasOverrides = $derived(Object.keys(overrides).length > 0);
</script>
<PageShell
{widthPx}
{maximized}
{onClose}
{onMaximize}
{onResize}
{onMoveLeft}
{onMoveRight}
title={meta.title}
color={meta.color}
icon={meta.icon}
>
<div class="policy">
<header class="info">
<p>
Pro Tool festlegen was passiert wenn die KI es aufruft.
<strong>auto</strong> führt sofort aus,
<strong>propose</strong> stagt als Vorschlag zur Freigabe,
<strong>deny</strong> sperrt das Tool komplett.
</p>
{#if hasOverrides}
<button type="button" class="reset" onclick={resetAll}>
Zurücksetzen ({Object.keys(overrides).length})
</button>
{/if}
</header>
{#each grouped as [mod, list] (mod)}
<section>
<h3>{mod}</h3>
<ul>
{#each list as t (t.name)}
{@const current = decide(t.name)}
{@const overridden = overrides[t.name] !== undefined}
<li class:overridden>
<div class="t-info">
<span class="t-name">{t.name}</span>
<span class="t-desc">{t.description}</span>
</div>
<div class="t-picker" role="radiogroup" aria-label={t.name}>
{#each ['auto', 'propose', 'deny'] as PolicyDecision[] as d}
<button
type="button"
class="pill pill-{d}"
class:active={current === d}
onclick={() => setDecision(t.name, d)}
aria-checked={current === d}
role="radio"
>
{d}
</button>
{/each}
</div>
</li>
{/each}
</ul>
</section>
{/each}
</div>
</PageShell>
<style>
.policy {
padding: 0.75rem 1rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.info {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.info p {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.45;
}
.reset {
flex-shrink: 0;
padding: 0.375rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-surface));
cursor: pointer;
font: inherit;
font-size: 0.75rem;
}
section h3 {
margin: 0 0 0.375rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: hsl(var(--color-muted-foreground));
}
section ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
section li {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem;
align-items: center;
padding: 0.5rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
}
section li.overridden {
border-left: 3px solid hsl(var(--color-primary));
}
.t-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.t-name {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8125rem;
}
.t-desc {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.t-picker {
display: inline-flex;
border: 1px solid hsl(var(--color-border));
border-radius: 999px;
overflow: hidden;
}
.pill {
border: none;
padding: 0.25rem 0.625rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-muted-foreground));
font: inherit;
font-size: 0.75rem;
cursor: pointer;
}
.pill + .pill {
border-left: 1px solid hsl(var(--color-border));
}
.pill.active.pill-auto {
background: #d7f7e3;
color: #1b7a3a;
}
.pill.active.pill-propose {
background: #fef0c9;
color: #8a5a00;
}
.pill.active.pill-deny {
background: #f7d7d7;
color: #8a1b1b;
}
</style>

View file

@ -0,0 +1,207 @@
<!--
Rituals page — guided routines. Wraps the existing RitualRunner
component from the companion module.
-->
<script lang="ts">
import { PageShell } from '$lib/components/page-carousel';
import { Plus, Play, Trash } from '@mana/shared-icons';
import RitualRunner from '$lib/modules/companion/components/RitualRunner.svelte';
import { ritualStore, useAllRituals, RITUAL_TEMPLATES } from '$lib/companion/rituals';
import type { LocalRitual } from '$lib/companion/rituals/types';
import { COMPANION_PAGE_META } from './page-meta';
interface Props {
widthPx: number;
maximized?: boolean;
onClose: () => void;
onMaximize: () => void;
onResize: (widthPx: number, heightPx?: number) => void;
onMoveLeft?: () => void;
onMoveRight?: () => void;
}
let {
widthPx,
maximized = false,
onClose,
onMaximize,
onResize,
onMoveLeft,
onMoveRight,
}: Props = $props();
const meta = COMPANION_PAGE_META.rituals;
const rituals = useAllRituals();
let activeRitual = $state<LocalRitual | null>(null);
let showTemplates = $state(false);
async function createFromTemplate(templateId: string) {
const template = RITUAL_TEMPLATES.find((t) => t.id === templateId);
if (!template) return;
await ritualStore.createFromTemplate(template);
showTemplates = false;
}
</script>
<PageShell
{widthPx}
{maximized}
{onClose}
{onMaximize}
{onResize}
{onMoveLeft}
{onMoveRight}
title={meta.title}
color={meta.color}
icon={meta.icon}
>
<div class="r">
{#if activeRitual}
<button class="back" onclick={() => (activeRitual = null)}> Zurück</button>
<RitualRunner
ritual={activeRitual}
onComplete={() => (activeRitual = null)}
onClose={() => (activeRitual = null)}
/>
{:else}
<header class="bar">
<button type="button" class="primary" onclick={() => (showTemplates = !showTemplates)}>
<Plus size={14} /><span>Aus Template</span>
</button>
</header>
{#if showTemplates}
<div class="templates">
{#each RITUAL_TEMPLATES as t}
<button type="button" class="template" onclick={() => createFromTemplate(t.id)}>
<strong>{t.title}</strong>
<span>{t.description ?? ''}</span>
</button>
{/each}
</div>
{/if}
<ul class="list">
{#each rituals.value as r (r.id)}
<li class="item">
<button type="button" class="item-main" onclick={() => (activeRitual = r)}>
<Play size={12} />
<span>{r.title}</span>
</button>
<button
type="button"
class="item-del"
onclick={() => ritualStore.delete(r.id)}
title="Löschen"
>
<Trash size={11} />
</button>
</li>
{/each}
{#if rituals.value.length === 0 && !showTemplates}
<li class="empty">Noch keine Rituale — erstelle eines aus einer Vorlage oben.</li>
{/if}
</ul>
{/if}
</div>
</PageShell>
<style>
.r {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem 1rem 1.25rem;
}
.back {
align-self: flex-start;
border: none;
background: none;
padding: 0.25rem 0;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
}
.bar {
display: flex;
justify-content: flex-end;
}
.primary {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.625rem;
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;
}
.templates {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem;
border: 1px dashed hsl(var(--color-border));
border-radius: 0.375rem;
}
.template {
text-align: left;
padding: 0.5rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-surface));
cursor: pointer;
font: inherit;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.template span {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.item {
display: flex;
align-items: center;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
}
.item-main {
flex: 1;
display: inline-flex;
gap: 0.375rem;
align-items: center;
padding: 0.5rem 0.625rem;
border: none;
background: none;
cursor: pointer;
font: inherit;
font-size: 0.875rem;
color: hsl(var(--color-foreground));
text-align: left;
}
.item-del {
border: none;
background: none;
padding: 0.375rem 0.5rem;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
}
.empty {
list-style: none;
color: hsl(var(--color-muted-foreground));
padding: 1rem 0;
font-size: 0.875rem;
}
</style>

View file

@ -0,0 +1,290 @@
<!--
Workbench page — AI activity timeline grouped by mission iteration,
with per-bucket Revert button. Replaces /companion/workbench.
-->
<script lang="ts">
import { PageShell } from '$lib/components/page-carousel';
import { ArrowSquareOut, ArrowCounterClockwise } from '@mana/shared-icons';
import { COMPANION_PAGE_META } from './page-meta';
import { useAiTimeline, bucketByIteration } from '$lib/data/ai/timeline/queries';
import { useMissions } from '$lib/data/ai/missions/queries';
import { revertIteration } from '$lib/data/ai/revert/revert-iteration';
import type { DomainEvent } from '$lib/data/events/types';
interface Props {
widthPx: number;
maximized?: boolean;
onClose: () => void;
onMaximize: () => void;
onResize: (widthPx: number, heightPx?: number) => void;
onMoveLeft?: () => void;
onMoveRight?: () => void;
}
let {
widthPx,
maximized = false,
onClose,
onMaximize,
onResize,
onMoveLeft,
onMoveRight,
}: Props = $props();
const meta = COMPANION_PAGE_META.workbench;
let moduleFilter = $state<string | null>(null);
let missionFilter = $state<string | null>(null);
const events = $derived(
useAiTimeline({
missionId: missionFilter ?? undefined,
module: moduleFilter ?? undefined,
limit: 500,
})
);
const buckets = $derived(bucketByIteration(events.value));
const missions = $derived(useMissions());
const missionTitleById = $derived(new Map(missions.value.map((m) => [m.id, m.title])));
const allModules = $derived(Array.from(new Set(events.value.map((e) => e.meta.appId))).sort());
function describeEvent(e: DomainEvent): string {
const payload = e.payload as Record<string, unknown> | undefined;
const title =
payload && 'title' in payload && typeof payload.title === 'string'
? payload.title
: undefined;
return title ? `${e.type} · ${title}` : e.type;
}
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
}
let revertingKey = $state<string | null>(null);
async function handleRevert(bucketKey: string, missionId: string, iterationId: string) {
if (!confirm('Alle AI-Writes dieser Iteration zurücknehmen?')) return;
revertingKey = bucketKey;
try {
const stats = await revertIteration(missionId, iterationId);
const parts = [`${stats.reverted} zurückgenommen`];
if (stats.skippedUnsupported > 0) parts.push(`${stats.skippedUnsupported} nicht unterstützt`);
if (stats.failed > 0) parts.push(`${stats.failed} fehlgeschlagen`);
alert(parts.join(' · '));
} catch (err) {
console.error('[workbench] revert failed:', err);
alert('Revert fehlgeschlagen — siehe Console.');
} finally {
revertingKey = null;
}
}
</script>
<PageShell
{widthPx}
{maximized}
{onClose}
{onMaximize}
{onResize}
{onMoveLeft}
{onMoveRight}
title={meta.title}
color={meta.color}
icon={meta.icon}
>
<div class="wb">
<div class="filters">
<label>
<span class="lbl">Modul</span>
<select bind:value={moduleFilter}>
<option value={null}>alle</option>
{#each allModules as m}
<option value={m}>{m}</option>
{/each}
</select>
</label>
<label>
<span class="lbl">Mission</span>
<select bind:value={missionFilter}>
<option value={null}>alle</option>
{#each missions.value as m (m.id)}
<option value={m.id}>{m.title}</option>
{/each}
</select>
</label>
</div>
{#if buckets.length === 0}
<p class="empty">
Noch keine AI-Aktivität{moduleFilter ? ` in ${moduleFilter}` : ''}. Sobald eine Mission
läuft und Proposals approved werden, erscheinen hier die Änderungen.
</p>
{:else}
<ol class="timeline">
{#each buckets as b (b.key)}
<li class="bucket">
<header class="bucket-head">
<div class="when">
<span class="date">{formatDate(b.firstTimestamp)}</span>
<span class="time">{formatTime(b.firstTimestamp)}</span>
</div>
<div class="title-col">
<span class="mission-title">
{missionTitleById.get(b.missionId) ?? b.missionId}
</span>
{#if b.rationale}
<p class="rationale">{b.rationale}</p>
{/if}
</div>
<button
type="button"
class="revert"
disabled={revertingKey !== null}
onclick={() => handleRevert(b.key, b.missionId, b.iterationId)}
>
<ArrowCounterClockwise size={12} />
<span>{revertingKey === b.key ? 'Läuft…' : 'Revert'}</span>
</button>
</header>
<ul class="events">
{#each b.events as e (e.meta.id)}
<li class="event">
<span class="mod">{e.meta.appId}</span>
<span class="desc">{describeEvent(e)}</span>
<a class="link" href={`/${e.meta.appId}`} title="Zum Modul">
<ArrowSquareOut size={11} />
</a>
</li>
{/each}
</ul>
</li>
{/each}
</ol>
{/if}
</div>
</PageShell>
<style>
.wb {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem 1rem 1.25rem;
}
.filters {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.filters label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.lbl {
font-size: 0.6875rem;
text-transform: uppercase;
color: hsl(var(--color-muted-foreground));
}
.filters select {
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.25rem;
font: inherit;
font-size: 0.8125rem;
}
.empty {
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
text-align: center;
padding: 2rem 0;
}
.timeline {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.bucket {
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
padding: 0.625rem 0.75rem;
}
.bucket-head {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.5rem;
margin-bottom: 0.375rem;
}
.when {
display: flex;
flex-direction: column;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
.when .date {
font-size: 0.75rem;
}
.when .time {
font-size: 0.6875rem;
}
.mission-title {
font-weight: 600;
color: hsl(var(--color-primary));
}
.rationale {
margin: 0.125rem 0 0;
font-size: 0.75rem;
font-style: italic;
color: hsl(var(--color-muted-foreground));
}
.revert {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.25rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-muted-foreground));
font: inherit;
font-size: 0.6875rem;
cursor: pointer;
}
.revert:hover:not(:disabled) {
color: #8a1b1b;
border-color: #e99;
background: #fff0f0;
}
.events {
list-style: none;
padding: 0 0 0 3.5rem;
margin: 0;
}
.event {
display: grid;
grid-template-columns: 4.5rem 1fr auto;
gap: 0.375rem;
padding: 0.25rem 0;
align-items: center;
font-size: 0.8125rem;
border-top: 1px dashed hsl(var(--color-border));
}
.event:first-child {
border-top: none;
}
.mod {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.625rem;
text-transform: uppercase;
color: hsl(var(--color-muted-foreground));
}
.link {
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,99 @@
/**
* Static metadata for every page the Companion carousel can render.
*
* Matches the shape modules like `/todo` use in their
* `PAGE_META: Record<string, { title; color }>` adapted here to the
* Companion's own page set. Colors map loosely to the activity type
* (data: muted, creative: primary-ish, health: green, policy: orange).
*/
import { Sparkle, ChatCircle, Flag, Notebook, Lightning, Heartbeat } from '@mana/shared-icons';
import type { Component } from 'svelte';
import type { CompanionPageId } from '../stores/workbench-settings.svelte';
export interface CompanionPageMeta {
id: CompanionPageId;
title: string;
shortLabel: string;
color: string;
icon: Component;
description: string;
}
export const COMPANION_PAGE_META: Record<CompanionPageId, CompanionPageMeta> = {
home: {
id: 'home',
title: 'Companion',
shortLabel: 'Home',
color: '#6B5BFF',
icon: Sparkle,
description: 'Übersicht, schnelle Einstiege, letzte Aktivität.',
},
chat: {
id: 'chat',
title: 'Chat',
shortLabel: 'Chat',
color: '#3B82F6',
icon: ChatCircle,
description: 'Gespräch mit der KI.',
},
missions: {
id: 'missions',
title: 'Missions',
shortLabel: 'Missions',
color: '#22C55E',
icon: Flag,
description: 'Langlebige Aufträge an die KI anlegen, pausieren, ausführen.',
},
workbench: {
id: 'workbench',
title: 'Workbench',
shortLabel: 'Workbench',
color: '#F59E0B',
icon: Notebook,
description: 'Timeline aller KI-Aktivität; rückgängig machen.',
},
rituals: {
id: 'rituals',
title: 'Rituale',
shortLabel: 'Rituale',
color: '#EC4899',
icon: Lightning,
description: 'Geführte Routinen (Morgen, Abend, …).',
},
policy: {
id: 'policy',
title: 'Policy',
shortLabel: 'Policy',
color: '#F97316',
icon: Flag,
description: 'Pro Tool festlegen: auto / propose / deny.',
},
insights: {
id: 'insights',
title: 'Insights',
shortLabel: 'Insights',
color: '#8B5CF6',
icon: Notebook,
description: 'Approval-Raten, Feedback-Muster, Stats pro Mission.',
},
health: {
id: 'health',
title: 'Health',
shortLabel: 'Health',
color: '#10B981',
icon: Heartbeat,
description: 'Runner-Status, letzter Tick, LLM-Backend.',
},
};
export const ALL_COMPANION_PAGE_IDS: readonly CompanionPageId[] = [
'home',
'chat',
'missions',
'workbench',
'rituals',
'policy',
'insights',
'health',
];

View file

@ -0,0 +1,100 @@
/**
* Companion workbench settings which pages the user currently has
* open + their widths. Persisted to localStorage so layout survives
* reloads; keep it tiny on purpose (no Dexie round-trip for every
* resize).
*/
export type CompanionPageId =
| 'home'
| 'chat'
| 'missions'
| 'workbench'
| 'rituals'
| 'policy'
| 'insights'
| 'health';
export interface CompanionOpenPage {
id: CompanionPageId;
widthPx: number;
heightPx?: number;
maximized?: boolean;
}
const STORAGE_KEY = 'companion:openPages';
const DEFAULT_WIDTH = 520;
/** Fresh users open the home page with shortcuts to everything else. */
const DEFAULT_OPEN_PAGES: CompanionOpenPage[] = [{ id: 'home', widthPx: DEFAULT_WIDTH }];
function loadOpenPages(): CompanionOpenPage[] {
if (typeof localStorage === 'undefined') return DEFAULT_OPEN_PAGES;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_OPEN_PAGES;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length === 0) return DEFAULT_OPEN_PAGES;
return parsed as CompanionOpenPage[];
} catch {
return DEFAULT_OPEN_PAGES;
}
}
function persist(pages: CompanionOpenPage[]): void {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(pages));
} catch {
// ignore quota / private-mode failures — layout falls back to default next load
}
}
function createStore() {
let openPages = $state<CompanionOpenPage[]>(loadOpenPages());
return {
get openPages() {
return openPages;
},
openPage(id: CompanionPageId) {
if (openPages.some((p) => p.id === id)) return;
openPages = [...openPages, { id, widthPx: DEFAULT_WIDTH }];
persist(openPages);
},
closePage(id: CompanionPageId) {
openPages = openPages.filter((p) => p.id !== id);
persist(openPages);
},
resize(id: CompanionPageId, widthPx: number, heightPx?: number) {
openPages = openPages.map((p) => (p.id === id ? { ...p, widthPx, heightPx } : p));
persist(openPages);
},
toggleMaximized(id: CompanionPageId) {
openPages = openPages.map((p) => (p.id === id ? { ...p, maximized: !p.maximized } : p));
persist(openPages);
},
moveLeft(id: CompanionPageId) {
const idx = openPages.findIndex((p) => p.id === id);
if (idx <= 0) return;
const next = [...openPages];
[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
openPages = next;
persist(openPages);
},
moveRight(id: CompanionPageId) {
const idx = openPages.findIndex((p) => p.id === id);
if (idx === -1 || idx >= openPages.length - 1) return;
const next = [...openPages];
[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
openPages = next;
persist(openPages);
},
reset() {
openPages = [...DEFAULT_OPEN_PAGES];
persist(openPages);
},
};
}
export const companionWorkbenchSettings = createStore();

View file

@ -1,39 +1,66 @@
<!--
Companion — AI-Workbench carousel.
Replaces the former split `/companion + /companion/missions +
/companion/workbench + /companion/rituals` sub-routes with a single
PageCarousel. Each feature (Chat, Missions, Workbench, Rituals,
Policy, Insights, Health) lives as one page; user opens / closes /
reorders / resizes them. Layout persists via localStorage
(`companionWorkbenchSettings`).
-->
<script lang="ts">
import { onMount } from 'svelte';
import { Robot, Plus, Trash } from '@mana/shared-icons';
import CompanionChat from '$lib/modules/companion/components/CompanionChat.svelte';
import { chatStore } from '$lib/modules/companion/stores/chat.svelte';
import { useConversations } from '$lib/modules/companion/queries';
import type { LocalConversation } from '$lib/modules/companion/types';
import { PageCarousel } from '$lib/components/page-carousel';
import { companionWorkbenchSettings } from '$lib/modules/companion/stores/workbench-settings.svelte';
import type { CompanionPageId } from '$lib/modules/companion/stores/workbench-settings.svelte';
import { COMPANION_PAGE_META } from '$lib/modules/companion/pages/page-meta';
import PagePicker from '$lib/modules/companion/pages/PagePicker.svelte';
import AiHomePage from '$lib/modules/companion/pages/AiHomePage.svelte';
import ChatPage from '$lib/modules/companion/pages/ChatPage.svelte';
import MissionsPage from '$lib/modules/companion/pages/MissionsPage.svelte';
import WorkbenchPage from '$lib/modules/companion/pages/WorkbenchPage.svelte';
import RitualsPage from '$lib/modules/companion/pages/RitualsPage.svelte';
import PolicyPage from '$lib/modules/companion/pages/PolicyPage.svelte';
import InsightsPage from '$lib/modules/companion/pages/InsightsPage.svelte';
import HealthPage from '$lib/modules/companion/pages/HealthPage.svelte';
import type { CarouselPage } from '$lib/components/page-carousel';
const conversations = useConversations();
let showPicker = $state(false);
let activeConversation = $state<LocalConversation | null>(null);
const openPages = $derived(companionWorkbenchSettings.openPages);
onMount(async () => {
// Auto-create or resume last conversation
if (conversations.value.length > 0) {
activeConversation = conversations.value[0];
}
});
const carouselPages = $derived<CarouselPage[]>(
openPages.map((p) => {
const meta = COMPANION_PAGE_META[p.id];
return {
id: p.id,
title: meta.title,
color: meta.color,
icon: meta.icon,
widthPx: p.widthPx,
heightPx: p.heightPx,
maximized: p.maximized ?? false,
};
})
);
// When conversations load, select the first one
$effect(() => {
if (!activeConversation && conversations.value.length > 0) {
activeConversation = conversations.value[0];
}
});
async function handleNewConversation() {
const conv = await chatStore.createConversation();
activeConversation = conv;
function close(id: CompanionPageId) {
companionWorkbenchSettings.closePage(id);
}
async function handleDeleteConversation(id: string) {
await chatStore.deleteConversation(id);
if (activeConversation?.id === id) {
activeConversation = conversations.value.find((c) => c.id !== id) ?? null;
}
function maximize(id: CompanionPageId) {
companionWorkbenchSettings.toggleMaximized(id);
}
function resize(id: CompanionPageId, widthPx: number, heightPx?: number) {
companionWorkbenchSettings.resize(id, widthPx, heightPx);
}
function moveLeft(id: CompanionPageId) {
companionWorkbenchSettings.moveLeft(id);
}
function moveRight(id: CompanionPageId) {
companionWorkbenchSettings.moveRight(id);
}
function pick(id: CompanionPageId) {
companionWorkbenchSettings.openPage(id);
showPicker = false;
}
</script>
@ -41,268 +68,51 @@
<title>Companion - Mana</title>
</svelte:head>
<div class="companion-page">
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-header">
<div class="sidebar-title">
<Robot size={20} weight="bold" />
<span>Companion</span>
</div>
<button class="new-btn" onclick={handleNewConversation} title="Neues Gespraech">
<Plus size={16} weight="bold" />
</button>
</div>
<PageCarousel
pages={carouselPages}
{showPicker}
onTogglePicker={() => (showPicker = !showPicker)}
addLabel="Page hinzufügen"
>
{#snippet page(p)}
{@const pageId = p.id as CompanionPageId}
{@const idx = openPages.findIndex((o) => o.id === pageId)}
{@const first = idx === 0}
{@const last = idx === openPages.length - 1}
{@const shellProps = {
widthPx: p.widthPx,
maximized: p.maximized,
onClose: () => close(pageId),
onMaximize: () => maximize(pageId),
onResize: (w: number, h?: number) => resize(pageId, w, h),
onMoveLeft: first ? undefined : () => moveLeft(pageId),
onMoveRight: last ? undefined : () => moveRight(pageId),
}}
<div class="conversation-list">
{#each conversations.value as conv (conv.id)}
<button
class="conversation-item"
class:active={activeConversation?.id === conv.id}
onclick={() => (activeConversation = conv)}
>
<span class="conv-title">{conv.title}</span>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="conv-delete"
role="button"
tabindex="-1"
onclick={(e) => {
e.stopPropagation();
handleDeleteConversation(conv.id);
}}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
handleDeleteConversation(conv.id);
}
}}
title="Loeschen"
>
<Trash size={12} />
</span>
</button>
{/each}
{#if conversations.value.length === 0}
<p class="empty-hint">Noch keine Gespraeche. Starte mit dem + Button.</p>
{/if}
</div>
<nav class="sidebar-footer">
<a href="/companion/missions">AI Missions →</a>
<a href="/companion/workbench">Workbench →</a>
<a href="/companion/rituals">Rituale →</a>
</nav>
</div>
<!-- Chat Area -->
<div class="chat-area">
{#if activeConversation}
{#key activeConversation.id}
<CompanionChat conversation={activeConversation} />
{/key}
{:else}
<div class="empty-state">
<Robot size={48} weight="thin" />
<h2>Mana Companion</h2>
<p>
Dein persoenlicher Assistent. Frag nach deinem Tag, lass Tasks erstellen oder Getraenke
loggen.
</p>
<button class="start-btn" onclick={handleNewConversation}> Gespraech starten </button>
</div>
{#if pageId === 'home'}
<AiHomePage {...shellProps} />
{:else if pageId === 'chat'}
<ChatPage {...shellProps} />
{:else if pageId === 'missions'}
<MissionsPage {...shellProps} />
{:else if pageId === 'workbench'}
<WorkbenchPage {...shellProps} />
{:else if pageId === 'rituals'}
<RitualsPage {...shellProps} />
{:else if pageId === 'policy'}
<PolicyPage {...shellProps} />
{:else if pageId === 'insights'}
<InsightsPage {...shellProps} />
{:else if pageId === 'health'}
<HealthPage {...shellProps} />
{/if}
</div>
</div>
{/snippet}
<style>
.companion-page {
display: flex;
min-height: 400px;
height: calc(100dvh - var(--bottom-chrome-height, 80px) - 6rem);
border: 1px solid hsl(var(--color-border));
border-radius: 1rem;
overflow: hidden;
background: hsl(var(--color-background));
}
.sidebar {
width: 240px;
flex-shrink: 0;
background: hsl(var(--color-card));
display: flex;
flex-direction: column;
}
@media (max-width: 639px) {
.sidebar {
display: none;
}
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.sidebar-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
font-size: 0.9375rem;
color: hsl(var(--color-foreground));
}
.new-btn {
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.new-btn:hover {
background: hsl(var(--color-primary) / 0.2);
}
.conversation-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.conversation-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: none;
background: transparent;
color: hsl(var(--color-foreground));
cursor: pointer;
font-size: 0.8125rem;
text-align: left;
transition: all 0.15s;
}
.conversation-item:hover {
background: hsl(var(--color-surface-hover));
}
.conversation-item.active {
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
}
.conv-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conv-delete {
flex-shrink: 0;
opacity: 0;
border: none;
background: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
padding: 0.125rem;
border-radius: 0.25rem;
display: flex;
transition: all 0.15s;
}
.conversation-item:hover .conv-delete {
opacity: 1;
}
.conv-delete:hover {
color: hsl(var(--color-error));
}
.empty-hint {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
padding: 1rem;
}
.chat-area {
flex: 1;
background: hsl(var(--color-background));
display: flex;
flex-direction: column;
}
.empty-state {
flex: 1;
min-height: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: hsl(var(--color-muted-foreground));
padding: 2rem;
text-align: center;
}
.empty-state h2 {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0;
}
.empty-state p {
max-width: 320px;
font-size: 0.875rem;
line-height: 1.5;
}
.start-btn {
padding: 0.625rem 1.25rem;
border-radius: 9999px;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.start-btn:hover {
filter: brightness(1.1);
}
.sidebar-footer {
padding: 0.75rem 1rem;
border-top: 1px solid var(--color-border, #ddd);
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.sidebar-footer a {
color: var(--color-muted, #888);
text-decoration: none;
font-size: 0.8125rem;
}
.sidebar-footer a:hover {
color: var(--color-primary, #6b5bff);
}
</style>
{#snippet picker()}
<PagePicker
openIds={openPages.map((p) => p.id)}
onPick={pick}
onClose={() => (showPicker = false)}
/>
{/snippet}
</PageCarousel>

View file

@ -1,717 +0,0 @@
<!--
/companion/missions — Create and review AI Missions.
Minimal MVP: list on the left, expanded detail on the right (or below
on narrow screens). Inline create form at the top. Clicking a mission
shows its iteration history + controls (pause/resume/complete/delete).
No input picker yet — missions can be created with only concept +
objective + cadence. Linking to notes / goals / kontext is a follow-up.
-->
<script lang="ts">
import { Sparkle, Play, Pause, Check, Trash, Plus, ArrowLeft } from '@mana/shared-icons';
import { useMissions } from '$lib/data/ai/missions/queries';
import {
createMission,
pauseMission,
resumeMission,
completeMission,
deleteMission,
addIterationFeedback,
} from '$lib/data/ai/missions/store';
import { runMission } from '$lib/data/ai/missions/runner';
import { productionDeps } from '$lib/data/ai/missions/setup';
import MissionInputPicker from '$lib/components/ai/MissionInputPicker.svelte';
import type { Mission, MissionCadence, MissionInputRef } from '$lib/data/ai/missions/types';
const missions = $derived(useMissions());
let selectedId = $state<string | null>(null);
const selected = $derived(missions.value.find((m) => m.id === selectedId) ?? null);
// ── Create form ────────────────────────────────────────
let showForm = $state(false);
let formTitle = $state('');
let formObjective = $state('');
let formConcept = $state('');
let formCadenceKind = $state<MissionCadence['kind']>('manual');
let formIntervalMin = $state(60);
let formDailyHour = $state(9);
let formInputs = $state<MissionInputRef[]>([]);
let creating = $state(false);
function buildCadence(): MissionCadence {
switch (formCadenceKind) {
case 'manual':
return { kind: 'manual' };
case 'interval':
return { kind: 'interval', everyMinutes: formIntervalMin };
case 'daily':
return { kind: 'daily', atHour: formDailyHour, atMinute: 0 };
case 'weekly':
return { kind: 'weekly', dayOfWeek: 1, atHour: formDailyHour };
case 'cron':
return { kind: 'cron', expression: '' };
}
}
async function handleCreate() {
if (!formTitle.trim() || !formObjective.trim()) return;
creating = true;
try {
const m = await createMission({
title: formTitle.trim(),
objective: formObjective.trim(),
conceptMarkdown: formConcept,
inputs: formInputs,
cadence: buildCadence(),
});
formTitle = '';
formObjective = '';
formConcept = '';
formInputs = [];
formCadenceKind = 'manual';
showForm = false;
selectedId = m.id;
} finally {
creating = false;
}
}
// ── Detail actions ─────────────────────────────────────
let runningNow = $state(false);
async function handleRunNow(m: Mission) {
runningNow = true;
try {
await runMission(m.id, productionDeps);
} catch (err) {
console.error(err);
} finally {
runningNow = false;
}
}
let feedbackDraft = $state('');
async function handleFeedback(m: Mission, iterationId: string) {
if (!feedbackDraft.trim()) return;
await addIterationFeedback(m.id, iterationId, feedbackDraft.trim());
feedbackDraft = '';
}
function describeCadence(c: MissionCadence): string {
switch (c.kind) {
case 'manual':
return 'manuell';
case 'interval':
return `alle ${c.everyMinutes} min`;
case 'daily':
return `täglich ${String(c.atHour).padStart(2, '0')}:${String(c.atMinute).padStart(2, '0')}`;
case 'weekly':
return `wöchentlich (Tag ${c.dayOfWeek}, ${c.atHour}:00)`;
case 'cron':
return `cron: ${c.expression}`;
}
}
function describeState(s: Mission['state']): string {
return { active: 'aktiv', paused: 'pausiert', done: 'abgeschlossen', archived: 'archiviert' }[
s
];
}
function formatRelative(iso: string | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
const deltaMs = d.getTime() - Date.now();
const abs = Math.abs(deltaMs);
const mins = Math.round(abs / 60_000);
const hours = Math.round(mins / 60);
const days = Math.round(hours / 24);
const value = mins < 60 ? `${mins}m` : hours < 24 ? `${hours}h` : `${days}d`;
return deltaMs < 0 ? `vor ${value}` : `in ${value}`;
}
</script>
<svelte:head>
<title>Missions - Companion</title>
</svelte:head>
<div class="page">
<header class="page-header">
<a href="/companion" class="back">
<ArrowLeft size={16} />
<span>Companion</span>
</a>
<div class="title">
<Sparkle size={20} weight="fill" />
<h1>AI Missions</h1>
</div>
<button type="button" class="primary-btn" onclick={() => (showForm = !showForm)}>
<Plus size={16} />
<span>{showForm ? 'Abbrechen' : 'Neue Mission'}</span>
</button>
</header>
{#if showForm}
<form class="create-form" onsubmit={(e) => (e.preventDefault(), handleCreate())}>
<label>
<span>Titel</span>
<input bind:value={formTitle} placeholder="z.B. Wöchentlicher Goals-Review" required />
</label>
<label>
<span>Konkretes Ziel</span>
<input bind:value={formObjective} placeholder="Was genau soll die KI erreichen?" required />
</label>
<label>
<span>Konzept (Markdown, optional)</span>
<textarea
bind:value={formConcept}
placeholder="# Rahmen&#10;Erkläre der KI Kontext, Regeln, Grenzen…"
rows="6"
></textarea>
</label>
<fieldset>
<legend>Inputs (Kontext für die KI)</legend>
<MissionInputPicker bind:value={formInputs} />
</fieldset>
<fieldset>
<legend>Cadence</legend>
<div class="cadence-row">
<label class="inline">
<input type="radio" bind:group={formCadenceKind} value="manual" />
<span>Manuell</span>
</label>
<label class="inline">
<input type="radio" bind:group={formCadenceKind} value="interval" />
<span>Intervall</span>
{#if formCadenceKind === 'interval'}
<input
class="inline-num"
type="number"
bind:value={formIntervalMin}
min="5"
max="1440"
/>
<span>min</span>
{/if}
</label>
<label class="inline">
<input type="radio" bind:group={formCadenceKind} value="daily" />
<span>Täglich um</span>
{#if formCadenceKind === 'daily'}
<input class="inline-num" type="number" bind:value={formDailyHour} min="0" max="23" />
<span>:00</span>
{/if}
</label>
</div>
</fieldset>
<div class="form-actions">
<button type="submit" class="primary-btn" disabled={creating}>
{creating ? 'Erstelle…' : 'Mission anlegen'}
</button>
</div>
</form>
{/if}
<div class="layout">
<aside class="list">
{#if missions.value.length === 0}
<p class="empty">Noch keine Missions. Starte mit "Neue Mission".</p>
{:else}
{#each missions.value as m (m.id)}
<button
type="button"
class="list-item"
class:selected={selectedId === m.id}
onclick={() => (selectedId = m.id)}
>
<div class="row-1">
<strong>{m.title}</strong>
<span class="badge badge-{m.state}">{describeState(m.state)}</span>
</div>
<div class="row-2">
<span>{describeCadence(m.cadence)}</span>
{#if m.nextRunAt}
<span>nächster Run {formatRelative(m.nextRunAt)}</span>
{/if}
<span>{m.iterations.length} Iteration{m.iterations.length === 1 ? '' : 'en'}</span>
</div>
</button>
{/each}
{/if}
</aside>
<section class="detail">
{#if selected}
<header class="detail-header">
<h2>{selected.title}</h2>
<div class="detail-actions">
<a
href="/companion/workbench?mission={selected.id}"
class="workbench-link"
title="Aktivität dieser Mission im Workbench"
>
Workbench →
</a>
<button type="button" onclick={() => handleRunNow(selected)} disabled={runningNow}>
<Play size={14} />
<span>{runningNow ? 'Läuft…' : 'Jetzt ausführen'}</span>
</button>
{#if selected.state === 'active'}
<button type="button" onclick={() => pauseMission(selected.id)}>
<Pause size={14} />
<span>Pausieren</span>
</button>
{:else if selected.state === 'paused'}
<button type="button" onclick={() => resumeMission(selected.id)}>
<Play size={14} />
<span>Fortsetzen</span>
</button>
{/if}
{#if selected.state !== 'done'}
<button type="button" onclick={() => completeMission(selected.id)}>
<Check size={14} />
<span>Abschließen</span>
</button>
{/if}
<button
type="button"
class="danger"
onclick={() => {
if (confirm('Mission löschen?')) {
deleteMission(selected.id);
selectedId = null;
}
}}
>
<Trash size={14} />
</button>
</div>
</header>
<dl class="meta">
<dt>Ziel</dt>
<dd>{selected.objective}</dd>
<dt>Cadence</dt>
<dd>{describeCadence(selected.cadence)}</dd>
<dt>Nächster Run</dt>
<dd>{formatRelative(selected.nextRunAt)}</dd>
<dt>Inputs</dt>
<dd>
{#if selected.inputs.length === 0}
{:else}
{selected.inputs.map((i) => `${i.module}/${i.id}`).join(', ')}
{/if}
</dd>
</dl>
{#if selected.conceptMarkdown}
<section class="concept">
<h3>Konzept</h3>
<pre>{selected.conceptMarkdown}</pre>
</section>
{/if}
<section class="iterations">
<h3>Iterationen</h3>
{#if selected.iterations.length === 0}
<p class="empty">Noch keine Iteration gelaufen.</p>
{:else}
{#each [...selected.iterations].reverse() as it (it.id)}
<article class="iteration">
<header>
<span class="it-date">{new Date(it.startedAt).toLocaleString('de-DE')}</span>
<span class="badge badge-status-{it.overallStatus}">{it.overallStatus}</span>
</header>
{#if it.summary}
<p class="it-summary">{it.summary}</p>
{/if}
{#if it.plan.length > 0}
<ul class="plan">
{#each it.plan as step}
<li>
<span class="step-status">[{step.status}]</span>
{#if step.summary}
{step.summary}
{:else if step.intent.kind === 'toolCall'}
{step.intent.toolName}
{:else}
Notiz
{/if}
</li>
{/each}
</ul>
{/if}
{#if it.userFeedback}
<blockquote class="feedback">{it.userFeedback}</blockquote>
{:else if !it.finishedAt || it.overallStatus === 'awaiting-review'}
<form
class="feedback-form"
onsubmit={(e) => (e.preventDefault(), handleFeedback(selected, it.id))}
>
<textarea
bind:value={feedbackDraft}
placeholder="Feedback für die nächste Iteration…"
rows="2"
></textarea>
<button type="submit" disabled={!feedbackDraft.trim()}>Speichern</button>
</form>
{/if}
</article>
{/each}
{/if}
</section>
{:else}
<p class="empty">Wähle links eine Mission aus, oder erstelle eine neue.</p>
{/if}
</section>
</div>
</div>
<style>
.page {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
}
.back {
display: inline-flex;
align-items: center;
gap: 0.375rem;
color: var(--color-muted, #888);
text-decoration: none;
font-size: 0.875rem;
}
.back:hover {
color: var(--color-fg, inherit);
}
.title {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.title h1 {
margin: 0;
font-size: 1.5rem;
}
.primary-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border: 1px solid color-mix(in oklab, var(--color-primary, #6b5bff) 45%, transparent);
border-radius: 0.375rem;
background: color-mix(in oklab, var(--color-primary, #6b5bff) 12%, var(--color-bg, #fff));
color: color-mix(in oklab, var(--color-primary, #6b5bff) 85%, var(--color-fg, #000));
font-size: 0.875rem;
cursor: pointer;
}
.primary-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.create-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border: 1px dashed var(--color-border, #ddd);
border-radius: 0.5rem;
margin-bottom: 1.25rem;
}
.create-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.875rem;
}
.create-form label > span {
font-weight: 500;
}
.create-form input:not([type]),
.create-form textarea {
padding: 0.5rem 0.625rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 0.375rem;
font: inherit;
}
.create-form textarea {
resize: vertical;
font-family: var(--font-mono, ui-monospace, monospace);
}
.create-form fieldset {
border: 1px solid var(--color-border, #ddd);
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
}
.cadence-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.inline {
flex-direction: row !important;
align-items: center;
gap: 0.375rem !important;
}
.inline-num {
width: 4.5rem;
}
.form-actions {
display: flex;
justify-content: flex-end;
}
.layout {
display: grid;
grid-template-columns: minmax(260px, 320px) 1fr;
gap: 1.25rem;
}
@media (max-width: 720px) {
.layout {
grid-template-columns: 1fr;
}
}
.list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.list-item {
text-align: left;
padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 0.375rem;
background: var(--color-bg, #fff);
cursor: pointer;
}
.list-item.selected {
border-color: color-mix(in oklab, var(--color-primary, #6b5bff) 55%, transparent);
background: color-mix(in oklab, var(--color-primary, #6b5bff) 6%, var(--color-bg, #fff));
}
.row-1 {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.row-2 {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
color: var(--color-muted, #888);
font-size: 0.75rem;
}
.badge {
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.04em;
background: var(--color-muted-bg, #eee);
}
.badge-active {
background: #d7f7e3;
color: #1b7a3a;
}
.badge-paused {
background: #fef0c9;
color: #8a5a00;
}
.badge-done {
background: #e0e5ec;
color: #3b4252;
}
.badge-archived {
background: #e8e8e8;
color: #666;
}
.badge-status-running {
background: #d7ecff;
color: #0a548b;
}
.badge-status-awaiting-review {
background: #fef0c9;
color: #8a5a00;
}
.badge-status-approved {
background: #d7f7e3;
color: #1b7a3a;
}
.badge-status-rejected,
.badge-status-failed {
background: #f7d7d7;
color: #8a1b1b;
}
.detail {
display: flex;
flex-direction: column;
gap: 1rem;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.detail-header h2 {
margin: 0;
font-size: 1.25rem;
}
.detail-actions {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
align-items: center;
}
.workbench-link {
padding: 0.375rem 0.625rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 0.375rem;
text-decoration: none;
font-size: 0.8125rem;
color: var(--color-muted, #666);
}
.workbench-link:hover {
color: var(--color-primary, #6b5bff);
border-color: color-mix(in oklab, var(--color-primary, #6b5bff) 45%, transparent);
}
.detail-actions button {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.625rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 0.375rem;
background: var(--color-bg, #fff);
cursor: pointer;
font-size: 0.8125rem;
}
.detail-actions button.danger {
color: #8a1b1b;
}
.detail-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.meta {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.25rem 1rem;
font-size: 0.875rem;
}
.meta dt {
color: var(--color-muted, #888);
}
.meta dd {
margin: 0;
}
.concept pre {
white-space: pre-wrap;
padding: 0.75rem;
background: color-mix(in oklab, var(--color-fg, #000) 3%, transparent);
border-radius: 0.375rem;
font-size: 0.8125rem;
margin: 0.5rem 0 0;
}
.iterations h3,
.concept h3 {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
}
.iteration {
border: 1px solid var(--color-border, #ddd);
border-radius: 0.375rem;
padding: 0.75rem 0.875rem;
margin-bottom: 0.5rem;
}
.iteration header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.375rem;
}
.it-date {
font-size: 0.75rem;
color: var(--color-muted, #888);
}
.it-summary {
margin: 0 0 0.5rem;
font-size: 0.875rem;
}
.plan {
list-style: none;
padding: 0;
margin: 0.25rem 0;
font-size: 0.8125rem;
}
.plan li {
padding: 0.125rem 0;
}
.step-status {
color: var(--color-muted, #888);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.75rem;
margin-right: 0.375rem;
}
.feedback {
margin: 0.5rem 0 0;
padding: 0.375rem 0.625rem;
border-left: 3px solid var(--color-border, #ddd);
font-style: italic;
color: var(--color-muted, #666);
font-size: 0.8125rem;
}
.feedback-form {
display: flex;
gap: 0.375rem;
margin-top: 0.5rem;
}
.feedback-form textarea {
flex: 1;
padding: 0.375rem 0.5rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 0.375rem;
font: inherit;
resize: vertical;
}
.feedback-form button {
align-self: flex-end;
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 0.375rem;
background: var(--color-bg, #fff);
cursor: pointer;
}
.empty {
color: var(--color-muted, #888);
font-size: 0.875rem;
padding: 1rem 0;
}
</style>

View file

@ -1,283 +0,0 @@
<script lang="ts">
import { Plus, Play, Pause, Trash } from '@mana/shared-icons';
import RitualRunner from '$lib/modules/companion/components/RitualRunner.svelte';
import {
ritualStore,
useActiveRituals,
useAllRituals,
RITUAL_TEMPLATES,
} from '$lib/companion/rituals';
import type { LocalRitual } from '$lib/companion/rituals/types';
const rituals = useAllRituals();
let activeRitual = $state<LocalRitual | null>(null);
let showTemplates = $state(false);
async function createFromTemplate(templateId: string) {
const template = RITUAL_TEMPLATES.find((t) => t.id === templateId);
if (!template) return;
await ritualStore.createFromTemplate(template);
showTemplates = false;
}
function startRitual(ritual: LocalRitual) {
activeRitual = ritual;
}
function handleComplete() {
activeRitual = null;
}
</script>
<svelte:head>
<title>Rituale - Mana Companion</title>
</svelte:head>
{#if activeRitual}
<RitualRunner
ritual={activeRitual}
onComplete={handleComplete}
onClose={() => (activeRitual = null)}
/>
{:else}
<div class="rituals-page">
<div class="page-header">
<h2>Rituale</h2>
<button class="add-btn" onclick={() => (showTemplates = !showTemplates)}>
<Plus size={16} weight="bold" /> Neu
</button>
</div>
{#if showTemplates}
<div class="templates">
<h3>Vorlage waehlen</h3>
{#each RITUAL_TEMPLATES as tpl}
<button class="template-card" onclick={() => createFromTemplate(tpl.id)}>
<span class="tpl-title">{tpl.title}</span>
<span class="tpl-desc">{tpl.description}</span>
<span class="tpl-trigger"
>{tpl.trigger === 'morning'
? 'Morgens'
: tpl.trigger === 'evening'
? 'Abends'
: 'Manuell'}</span
>
</button>
{/each}
</div>
{/if}
<div class="ritual-list">
{#each rituals.value as ritual (ritual.id)}
<div class="ritual-card">
<div class="ritual-info">
<span class="ritual-title">{ritual.title}</span>
{#if ritual.description}
<span class="ritual-desc">{ritual.description}</span>
{/if}
<span class="ritual-trigger">
{ritual.trigger === 'morning'
? 'Morgens'
: ritual.trigger === 'evening'
? 'Abends'
: 'Manuell'}
· {ritual.status === 'active' ? 'Aktiv' : 'Pausiert'}
</span>
</div>
<div class="ritual-actions">
{#if ritual.status === 'active'}
<button class="action-btn play" onclick={() => startRitual(ritual)} title="Starten">
<Play size={16} weight="fill" />
</button>
<button
class="action-btn"
onclick={() => ritualStore.pause(ritual.id)}
title="Pausieren"
>
<Pause size={14} weight="bold" />
</button>
{:else}
<button
class="action-btn"
onclick={() => ritualStore.resume(ritual.id)}
title="Fortsetzen"
>
<Play size={14} weight="bold" />
</button>
{/if}
<button
class="action-btn danger"
onclick={() => ritualStore.delete(ritual.id)}
title="Loeschen"
>
<Trash size={14} />
</button>
</div>
</div>
{:else}
<p class="empty">Noch keine Rituale. Erstelle eins aus einer Vorlage.</p>
{/each}
</div>
</div>
{/if}
<style>
.rituals-page {
max-width: 560px;
margin: 0 auto;
padding: 1rem;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.page-header h2 {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0;
}
.add-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
border-radius: 9999px;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.templates {
margin-bottom: 1.5rem;
}
.templates h3 {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
margin: 0 0 0.75rem;
}
.template-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 100%;
padding: 0.75rem 1rem;
border: 1.5px solid hsl(var(--color-border));
border-radius: 0.75rem;
background: hsl(var(--color-card));
cursor: pointer;
text-align: left;
margin-bottom: 0.5rem;
transition: all 0.15s;
}
.template-card:hover {
border-color: hsl(var(--color-primary) / 0.5);
background: hsl(var(--color-primary) / 0.03);
}
.tpl-title {
font-weight: 600;
font-size: 0.9375rem;
color: hsl(var(--color-foreground));
}
.tpl-desc {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.tpl-trigger {
font-size: 0.75rem;
color: hsl(var(--color-primary));
font-weight: 500;
}
.ritual-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ritual-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
background: hsl(var(--color-card));
}
.ritual-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.ritual-title {
font-weight: 500;
font-size: 0.9375rem;
color: hsl(var(--color-foreground));
}
.ritual-desc {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.ritual-trigger {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.ritual-actions {
display: flex;
gap: 0.25rem;
}
.action-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: hsl(var(--color-muted) / 0.2);
color: hsl(var(--color-muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.action-btn:hover {
background: hsl(var(--color-surface-hover));
color: hsl(var(--color-foreground));
}
.action-btn.play {
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
}
.action-btn.play:hover {
background: hsl(var(--color-primary) / 0.25);
}
.action-btn.danger:hover {
background: hsl(var(--color-error) / 0.15);
color: hsl(var(--color-error));
}
.empty {
text-align: center;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
padding: 2rem 1rem;
}
</style>

View file

@ -1,336 +0,0 @@
<!--
/companion/workbench — Cross-module timeline of AI activity.
Renders all events with `meta.actor.kind === 'ai'`, grouped into buckets
by mission iteration. Each bucket shows the rationale once (on the
iteration header) and lists the resulting record-level events below
with a link back into the originating module.
Filters: mission (via query-string `?mission=…`), module (dropdown).
-->
<script lang="ts">
import { Sparkle, ArrowLeft, ArrowSquareOut, ArrowCounterClockwise } from '@mana/shared-icons';
import { page } from '$app/stores';
import { useAiTimeline, bucketByIteration } from '$lib/data/ai/timeline/queries';
import { useMissions } from '$lib/data/ai/missions/queries';
import { revertIteration } from '$lib/data/ai/revert/revert-iteration';
import type { DomainEvent } from '$lib/data/events/types';
const missionId = $derived($page.url.searchParams.get('mission') ?? undefined);
let moduleFilter = $state<string | null>(null);
const events = $derived(
useAiTimeline({ missionId, module: moduleFilter ?? undefined, limit: 500 })
);
const buckets = $derived(bucketByIteration(events.value));
const missions = $derived(useMissions());
const missionTitleById = $derived(new Map(missions.value.map((m) => [m.id, m.title])));
const allModules = $derived(Array.from(new Set(events.value.map((e) => e.meta.appId))).sort());
function moduleRoute(event: DomainEvent): string {
// Most module pages live at /{module} — a best-effort deep link.
return `/${event.meta.appId}`;
}
function describeEvent(e: DomainEvent): string {
const payload = e.payload as Record<string, unknown> | undefined;
const title =
payload &&
typeof payload === 'object' &&
'title' in payload &&
typeof payload.title === 'string'
? payload.title
: undefined;
return title ? `${e.type} · ${title}` : e.type;
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
});
}
let revertingKey = $state<string | null>(null);
async function handleRevert(bucketKey: string, missionId: string, iterationId: string) {
if (!confirm('Alle AI-Writes dieser Iteration zurücknehmen?')) return;
revertingKey = bucketKey;
try {
const stats = await revertIteration(missionId, iterationId);
const parts = [`${stats.reverted} zurückgenommen`];
if (stats.skippedUnsupported > 0) parts.push(`${stats.skippedUnsupported} nicht unterstützt`);
if (stats.failed > 0) parts.push(`${stats.failed} fehlgeschlagen`);
alert(parts.join(' · '));
} catch (err) {
console.error('[workbench] revert failed:', err);
alert('Revert fehlgeschlagen — siehe Console.');
} finally {
revertingKey = null;
}
}
</script>
<svelte:head>
<title>Workbench - Companion</title>
</svelte:head>
<div class="page">
<header class="page-header">
<a href="/companion" class="back">
<ArrowLeft size={16} />
<span>Companion</span>
</a>
<div class="title">
<Sparkle size={20} weight="fill" />
<h1>AI Workbench</h1>
</div>
<div class="filters">
<label>
<span class="small">Modul</span>
<select bind:value={moduleFilter}>
<option value={null}>alle</option>
{#each allModules as mod}
<option value={mod}>{mod}</option>
{/each}
</select>
</label>
{#if missionId}
<a class="chip" href="/companion/workbench">
Mission: {missionTitleById.get(missionId) ?? missionId}
</a>
{/if}
</div>
</header>
{#if buckets.length === 0}
<p class="empty">
Noch keine AI-Aktivität{moduleFilter ? ` in ${moduleFilter}` : ''}. Sobald eine Mission läuft
und Proposals approved werden, erscheinen die Änderungen hier.
</p>
{:else}
<ol class="timeline">
{#each buckets as b (b.key)}
<li class="bucket">
<header class="bucket-head">
<div class="bucket-when">
<span class="date">{formatDate(b.firstTimestamp)}</span>
<span class="time">{formatTime(b.firstTimestamp)}</span>
</div>
<div class="bucket-title">
<a href="/companion/missions?m={b.missionId}" class="mission-link">
{missionTitleById.get(b.missionId) ?? b.missionId}
</a>
{#if b.rationale}
<p class="rationale">{b.rationale}</p>
{/if}
</div>
<button
type="button"
class="revert-btn"
title="Alle Writes dieser Iteration zurücknehmen"
disabled={revertingKey !== null}
onclick={() => handleRevert(b.key, b.missionId, b.iterationId)}
>
<ArrowCounterClockwise size={14} />
<span>{revertingKey === b.key ? 'Läuft…' : 'Revert'}</span>
</button>
</header>
<ul class="events">
{#each b.events as e (e.meta.id)}
<li class="event">
<span class="event-mod">{e.meta.appId}</span>
<span class="event-desc">{describeEvent(e)}</span>
<a class="event-link" href={moduleRoute(e)} title="Zum Modul">
<ArrowSquareOut size={12} />
</a>
</li>
{/each}
</ul>
</li>
{/each}
</ol>
{/if}
</div>
<style>
.page {
max-width: 960px;
margin: 0 auto;
padding: 1.5rem;
}
.page-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.back {
display: inline-flex;
align-items: center;
gap: 0.375rem;
color: var(--color-muted, #888);
text-decoration: none;
font-size: 0.875rem;
}
.title {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.title h1 {
margin: 0;
font-size: 1.5rem;
}
.filters {
display: inline-flex;
align-items: center;
gap: 0.75rem;
}
.filters label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.filters select {
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 0.25rem;
font: inherit;
}
.small {
font-size: 0.75rem;
color: var(--color-muted, #888);
}
.chip {
display: inline-flex;
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 999px;
font-size: 0.75rem;
color: var(--color-fg, inherit);
text-decoration: none;
background: color-mix(in oklab, var(--color-primary, #6b5bff) 8%, transparent);
}
.empty {
color: var(--color-muted, #888);
font-size: 0.9rem;
padding: 2rem 0;
text-align: center;
}
.timeline {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bucket {
border: 1px solid var(--color-border, #ddd);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
background: var(--color-bg, #fff);
}
.bucket-head {
display: grid;
grid-template-columns: 4.5rem 1fr auto;
gap: 0.75rem;
align-items: start;
margin-bottom: 0.5rem;
}
.revert-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 0.25rem;
background: var(--color-bg, #fff);
color: var(--color-muted, #666);
font: inherit;
font-size: 0.75rem;
cursor: pointer;
}
.revert-btn:hover:not(:disabled) {
color: #8a1b1b;
border-color: #e99;
background: #fff0f0;
}
.revert-btn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.bucket-when {
display: flex;
flex-direction: column;
color: var(--color-muted, #888);
font-variant-numeric: tabular-nums;
}
.bucket-when .date {
font-size: 0.8125rem;
}
.bucket-when .time {
font-size: 0.6875rem;
}
.mission-link {
font-weight: 600;
color: color-mix(in oklab, var(--color-primary, #6b5bff) 85%, var(--color-fg, #000));
text-decoration: none;
}
.mission-link:hover {
text-decoration: underline;
}
.rationale {
margin: 0.25rem 0 0;
color: var(--color-muted, #666);
font-size: 0.8125rem;
font-style: italic;
}
.events {
list-style: none;
padding: 0 0 0 5.25rem;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.event {
display: grid;
grid-template-columns: 5rem 1fr auto;
gap: 0.5rem;
align-items: center;
font-size: 0.8125rem;
padding: 0.25rem 0;
border-top: 1px dashed color-mix(in oklab, var(--color-border, #ddd) 70%, transparent);
}
.event:first-child {
border-top: none;
}
.event-mod {
color: var(--color-muted, #888);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.6875rem;
text-transform: uppercase;
}
.event-link {
color: var(--color-muted, #888);
display: inline-flex;
}
.event-link:hover {
color: var(--color-primary, #6b5bff);
}
</style>