feat(ai): AI features as top-level workbench apps (not sub-routes)

Reverts the previous /companion-carousel misstep. The user's model is
that Missions / Workbench / Rituals / Policy / Insights / Health each
live as their OWN app in the root `/` workbench scene, alongside todo /
calendar / notes / etc. — openable from the normal app picker, freely
combinable with any other module card.

- New modules, each with a ListView.svelte usable inside AppPage's
  PageShell (no self-wrapping, no shell-control props):
    `lib/modules/ai-missions/ListView.svelte`
    `lib/modules/ai-workbench/ListView.svelte`
    `lib/modules/ai-rituals/ListView.svelte`
    `lib/modules/ai-policy/ListView.svelte`
    `lib/modules/ai-insights/ListView.svelte`
    `lib/modules/ai-health/ListView.svelte`
- Registered in `app-registry/apps.ts`: `ai-missions`, `ai-workbench`,
  `ai-rituals`, `ai-policy`, `ai-insights`, `ai-health`. Each picks a
  distinct color + icon.
- `/companion/+page.svelte` restored to the simple chat it was before.
  The companion app (chat) remains as its own registered app —
  unchanged.
- Removed: `lib/modules/companion/pages/` + the
  `workbench-settings.svelte.ts` store (both were the dead-end
  PageCarousel approach).

User model now:
  ` / ` (workbench root) → [+ App] → any of the 6 AI apps + any module
  /companion                         → full-screen chat (unchanged)
  /todo, /calendar, …                → module-inline ghost inbox stays

svelte-check clean; webapp AI tests 71/71 green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 13:23:20 +02:00
parent 9686198a16
commit 37e39a5ddb
14 changed files with 924 additions and 1729 deletions

View file

@ -56,6 +56,9 @@ import {
Pulse,
Robot,
Target,
Flag,
Notebook,
Heartbeat,
Smiley,
Gear,
Palette,
@ -988,6 +991,71 @@ registerApp({
},
});
// ── AI Workbench apps — each feature is its own top-level app so it
// can live alongside other modules in the user's scene. Colors loosely
// group them: green=missions, amber=audit, pink=rituals, orange=policy,
// violet=insights, emerald=health.
registerApp({
id: 'ai-missions',
name: 'AI Missions',
color: '#22C55E',
icon: Flag,
views: {
list: { load: () => import('$lib/modules/ai-missions/ListView.svelte') },
},
});
registerApp({
id: 'ai-workbench',
name: 'AI Workbench',
color: '#F59E0B',
icon: Notebook,
views: {
list: { load: () => import('$lib/modules/ai-workbench/ListView.svelte') },
},
});
registerApp({
id: 'ai-rituals',
name: 'AI Rituale',
color: '#EC4899',
icon: Lightning,
views: {
list: { load: () => import('$lib/modules/ai-rituals/ListView.svelte') },
},
});
registerApp({
id: 'ai-policy',
name: 'AI Policy',
color: '#F97316',
icon: Flag,
views: {
list: { load: () => import('$lib/modules/ai-policy/ListView.svelte') },
},
});
registerApp({
id: 'ai-insights',
name: 'AI Insights',
color: '#8B5CF6',
icon: Notebook,
views: {
list: { load: () => import('$lib/modules/ai-insights/ListView.svelte') },
},
});
registerApp({
id: 'ai-health',
name: 'AI Health',
color: '#10B981',
icon: Heartbeat,
views: {
list: { load: () => import('$lib/modules/ai-health/ListView.svelte') },
},
});
registerApp({
id: 'goals',
name: 'Ziele',

View file

@ -0,0 +1,134 @@
<!--
AI Health app — runner status + manual tick + link to status page.
-->
<script lang="ts">
import { isMissionTickRunning, productionDeps } from '$lib/data/ai/missions/setup';
import { runDueMissions } from '$lib/data/ai/missions/runner';
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(err);
lastRunStats = { at: new Date().toLocaleString('de-DE'), plansProduced: 0, errors: 1 };
} finally {
manualRunning = false;
}
}
</script>
<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</h3>
<p class="desc">
Alles was hier angezeigt wird kommt lokal aus IndexedDB. Kein Server-Call außer beim Planner
selbst.
</p>
</section>
</div>
<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

@ -1,38 +1,13 @@
<!--
Insights page — local aggregation over `_events` and mission
iterations. No server calls; everything comes from Dexie liveQueries.
AI Insights app — local aggregation over _events + mission iterations.
-->
<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 }[] = [];
@ -49,7 +24,6 @@
});
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;
@ -62,25 +36,15 @@
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,
};
return { id: m.id, title: m.title, approved, rejected, failed, awaiting };
})
);
// 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);
}
@ -91,8 +55,6 @@
.map(([text, count]) => ({ text, count }));
});
// Simple approval rate: approved / (approved + rejected), ignoring
// awaiting/failed.
const approvalRate = $derived.by(() => {
let a = 0;
let r = 0;
@ -105,91 +67,75 @@
});
</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>
<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>
<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}
</div>
</section>
</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>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>
<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>
<style>
.ins {
@ -269,6 +215,7 @@
}
.n {
font-weight: 600;
margin: 0 0.125rem;
}
.n.ok {
color: #1b7a3a;

View file

@ -1,11 +1,10 @@
<!--
Missions page — list ↔ detail in a single panel. Replaces the former
/companion/missions sub-route with a master-detail-inline view.
AI Missions app — workbench card.
Renders inside AppPage, which provides the PageShell + window chrome.
Master-detail inline (list ↔ create ↔ detail) in a single panel.
-->
<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,
@ -20,27 +19,6 @@
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');
@ -49,7 +27,6 @@
selectedId ? (missions.value.find((m) => m.id === selectedId) ?? null) : null
);
// ── Create form ────────────────────────────────────────
let formTitle = $state('');
let formObjective = $state('');
let formConcept = $state('');
@ -97,7 +74,6 @@
}
}
// ── Detail actions ─────────────────────────────────────
let runningNow = $state(false);
async function handleRunNow(m: Mission) {
runningNow = true;
@ -131,13 +107,11 @@
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);
@ -155,209 +129,203 @@
}
</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>
{#if mode === 'list'}
<div class="pane">
<header class="bar">
<button type="button" class="primary" onclick={() => (mode = 'create')}>
<Plus size={14} /><span>Neue Mission</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>
</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}
{/if}
</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>
{/if}
</PageShell>
</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}
<style>
.pane-header {
.pane,
.create,
.detail {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.75rem 1rem 1.5rem;
}
.bar {
display: flex;
justify-content: flex-end;
padding: 0.5rem 0.75rem;
}
.primary {
display: inline-flex;
@ -374,7 +342,6 @@
}
.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty {
padding: 1.5rem 1rem;
@ -382,15 +349,10 @@
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;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
@ -423,7 +385,6 @@
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
flex-shrink: 0;
}
.dot-active {
background: #22c55e;
@ -443,14 +404,6 @@
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;
@ -476,7 +429,6 @@
font-weight: 600;
color: hsl(var(--color-muted-foreground));
}
.create input[type='text'],
.create input:not([type]),
.create textarea {
padding: 0.375rem 0.5rem;
@ -510,7 +462,6 @@
display: flex;
justify-content: flex-end;
}
.detail-title {
margin: 0;
font-size: 1.125rem;

View file

@ -1,42 +1,12 @@
<!--
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.
AI Policy app — per-tool 3-way toggle (auto / propose / deny).
Overrides stored in localStorage, merged into DEFAULT_AI_POLICY.
-->
<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> {
@ -49,7 +19,6 @@
return {};
}
}
function saveOverrides(overrides: Record<string, PolicyDecision>): void {
if (typeof localStorage === 'undefined') return;
try {
@ -58,7 +27,6 @@
// ignore
}
}
function applyPolicy(overrides: Record<string, PolicyDecision>): void {
const nextPolicy: AiPolicy = {
...DEFAULT_AI_POLICY,
@ -88,7 +56,6 @@
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) {
@ -98,75 +65,59 @@
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>
<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,
<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>
{#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>
<style>
.policy {

View file

@ -1,36 +1,12 @@
<!--
Rituals page — guided routines. Wraps the existing RitualRunner
component from the companion module.
AI Rituals app — guided routines. Wraps the existing RitualRunner.
-->
<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);
@ -43,68 +19,55 @@
}
</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>
<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>
{#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}
{#if rituals.value.length === 0 && !showTemplates}
<li class="empty">Noch keine Rituale — erstelle eines aus einer Vorlage oben.</li>
{/if}
</ul>
</div>
{/if}
</div>
</PageShell>
<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>
<style>
.r {

View file

@ -1,38 +1,14 @@
<!--
Workbench page — AI activity timeline grouped by mission iteration,
with per-bucket Revert button. Replaces /companion/workbench.
AI Workbench app — timeline of AI activity, grouped per mission
iteration. Per-bucket Revert button.
-->
<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);
@ -56,7 +32,6 @@
: 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' });
}
@ -65,9 +40,9 @@
}
let revertingKey = $state<string | null>(null);
async function handleRevert(bucketKey: string, missionId: string, iterationId: string) {
async function handleRevert(key: string, missionId: string, iterationId: string) {
if (!confirm('Alle AI-Writes dieser Iteration zurücknehmen?')) return;
revertingKey = bucketKey;
revertingKey = key;
try {
const stats = await revertIteration(missionId, iterationId);
const parts = [`${stats.reverted} zurückgenommen`];
@ -75,7 +50,7 @@
if (stats.failed > 0) parts.push(`${stats.failed} fehlgeschlagen`);
alert(parts.join(' · '));
} catch (err) {
console.error('[workbench] revert failed:', err);
console.error(err);
alert('Revert fehlgeschlagen — siehe Console.');
} finally {
revertingKey = null;
@ -83,89 +58,76 @@
}
</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>
<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}
</ol>
{/if}
</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>
</PageShell>
{#if buckets.length === 0}
<p class="empty">
Noch keine AI-Aktivität. 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>
<style>
.wb {

View file

@ -1,231 +0,0 @@
<!--
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

@ -1,224 +0,0 @@
<!--
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

@ -1,179 +0,0 @@
<!--
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

@ -1,138 +0,0 @@
<!--
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

@ -1,99 +0,0 @@
/**
* 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

@ -1,100 +0,0 @@
/**
* 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,66 +1,39 @@
<!--
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 { 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';
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';
let showPicker = $state(false);
const conversations = useConversations();
const openPages = $derived(companionWorkbenchSettings.openPages);
let activeConversation = $state<LocalConversation | null>(null);
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,
};
})
);
onMount(async () => {
// Auto-create or resume last conversation
if (conversations.value.length > 0) {
activeConversation = conversations.value[0];
}
});
function close(id: CompanionPageId) {
companionWorkbenchSettings.closePage(id);
// 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 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;
async function handleDeleteConversation(id: string) {
await chatStore.deleteConversation(id);
if (activeConversation?.id === id) {
activeConversation = conversations.value.find((c) => c.id !== id) ?? null;
}
}
</script>
@ -68,51 +41,268 @@
<title>Companion - Mana</title>
</svelte:head>
<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="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>
{#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} />
<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}
{/snippet}
</div>
</div>
{#snippet picker()}
<PagePicker
openIds={openPages.map((p) => p.id)}
onPick={pick}
onClose={() => (showPicker = false)}
/>
{/snippet}
</PageCarousel>
<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>