feat(ai): Workbench timeline — cross-module AI activity lens

Single-page view of everything the AI has done, grouped by mission
iteration. Closes the "what did the assistant actually touch today?"
question that used to require raw event-log spelunking.

- `data/ai/timeline/queries.ts`
  - `useAiTimeline({ missionId?, module?, limit? })` — reactive live
    query over `_events`, filtered to `actor.kind === 'ai'`. Over-fetches
    by 3x and client-filters because `actor.kind` isn't indexed; cap at
    500 entries keeps it cheap.
  - `bucketByIteration(events)` — groups events sharing
    `actor.missionId + actor.iterationId` into a single visual unit so
    the rationale reads once per iteration rather than once per event.
    Pure function, fully unit-tested.
- `routes/(app)/companion/workbench/+page.svelte`
  - Buckets rendered chronologically with mission-link header + rationale
  - Per-event row shows module + event type + payload title + deep-link
    back into the module
  - Module dropdown filter + `?mission=…` query-string for mission-scoped
    views (linked from /companion/missions detail header)
- `/companion` sidebar + missions detail header now link to the Workbench

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 23:21:04 +02:00
parent bf6b9cdd4b
commit 2fe9522953
5 changed files with 473 additions and 0 deletions

View file

@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest';
import { bucketByIteration } from './queries';
import type { DomainEvent } from '../../events/types';
import { USER_ACTOR } from '../../events/actor';
function aiEvent(
missionId: string,
iterationId: string,
rationale: string,
timestamp: string,
type = 'TaskCreated'
): DomainEvent {
return {
type,
payload: {},
meta: {
id: crypto.randomUUID(),
timestamp,
appId: 'todo',
collection: 'tasks',
recordId: crypto.randomUUID(),
userId: 'u1',
actor: { kind: 'ai', missionId, iterationId, rationale },
},
};
}
function userEvent(timestamp: string): DomainEvent {
return {
type: 'TaskCreated',
payload: {},
meta: {
id: crypto.randomUUID(),
timestamp,
appId: 'todo',
collection: 'tasks',
recordId: crypto.randomUUID(),
userId: 'u1',
actor: USER_ACTOR,
},
};
}
describe('bucketByIteration', () => {
it('groups events sharing iterationId', () => {
const buckets = bucketByIteration([
aiEvent('m-1', 'it-1', 'r', '2026-04-14T10:00:00Z'),
aiEvent('m-1', 'it-1', 'r', '2026-04-14T10:00:02Z'),
aiEvent('m-1', 'it-2', 'r2', '2026-04-14T11:00:00Z'),
]);
expect(buckets).toHaveLength(2);
expect(buckets[0].iterationId).toBe('it-2'); // sorted desc by firstTimestamp
expect(buckets[1].events).toHaveLength(2);
});
it('ignores user events entirely', () => {
const buckets = bucketByIteration([
userEvent('2026-04-14T10:00:00Z'),
aiEvent('m-1', 'it-1', 'r', '2026-04-14T10:00:01Z'),
]);
expect(buckets).toHaveLength(1);
expect(buckets[0].events).toHaveLength(1);
});
it('uses the earliest timestamp of the group as the bucket anchor', () => {
const buckets = bucketByIteration([
aiEvent('m-1', 'it-1', 'r', '2026-04-14T10:00:05Z'),
aiEvent('m-1', 'it-1', 'r', '2026-04-14T10:00:00Z'),
aiEvent('m-1', 'it-1', 'r', '2026-04-14T10:00:10Z'),
]);
expect(buckets).toHaveLength(1);
expect(buckets[0].firstTimestamp).toBe('2026-04-14T10:00:00Z');
});
it('separates events from different missions even at the same iterationId', () => {
const buckets = bucketByIteration([
aiEvent('m-1', 'it-1', 'r', '2026-04-14T10:00:00Z'),
aiEvent('m-2', 'it-1', 'r', '2026-04-14T10:00:01Z'),
]);
expect(buckets).toHaveLength(2);
});
});

View file

@ -0,0 +1,86 @@
/**
* Workbench Timeline queries.
*
* Reactive slices over the persisted `_events` table, filtered to the AI
* actor. The Workbench renders these chronologically so the user has a
* single place to see "what did the AI do today, last week, for this
* mission?" with rationale inline and a link back into each module.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '../../database';
import type { DomainEvent } from '../../events/types';
const EVENTS_TABLE = '_events';
export interface AiTimelineOptions {
/** Only events from this mission (matches `meta.actor.missionId`). */
missionId?: string;
/** Module filter — matches `meta.appId`. */
module?: string;
/** Max rows to return. Default 200, cap 1000. */
limit?: number;
}
/** Live query — rerenders on every persisted event. */
export function useAiTimeline(options: AiTimelineOptions = {}) {
const { missionId, module, limit = 200 } = options;
return useLiveQueryWithDefault(async () => {
const cap = Math.min(limit, 1000);
const all = (await db
.table(EVENTS_TABLE)
.orderBy('meta.timestamp')
.reverse()
.limit(cap * 3) // over-fetch because we filter client-side
.toArray()) as DomainEvent[];
return all
.filter((e) => e.meta.actor?.kind === 'ai')
.filter((e) => (module ? e.meta.appId === module : true))
.filter((e) => {
if (!missionId) return true;
const a = e.meta.actor;
return a?.kind === 'ai' && a.missionId === missionId;
})
.slice(0, cap);
}, [] as DomainEvent[]);
}
/**
* Group timeline events into iteration buckets for prettier rendering.
* Events share a bucket when their actor.iterationId matches.
*/
export interface TimelineBucket {
key: string;
missionId: string;
iterationId: string;
rationale: string;
firstTimestamp: string;
events: DomainEvent[];
}
export function bucketByIteration(events: readonly DomainEvent[]): TimelineBucket[] {
const buckets = new Map<string, TimelineBucket>();
for (const e of events) {
const a = e.meta.actor;
if (a?.kind !== 'ai') continue;
const key = `${a.missionId}::${a.iterationId}`;
const existing = buckets.get(key);
if (existing) {
existing.events.push(e);
if (e.meta.timestamp < existing.firstTimestamp) {
existing.firstTimestamp = e.meta.timestamp;
}
} else {
buckets.set(key, {
key,
missionId: a.missionId,
iterationId: a.iterationId,
rationale: a.rationale,
firstTimestamp: e.meta.timestamp,
events: [e],
});
}
}
return [...buckets.values()].sort((a, b) => (a.firstTimestamp < b.firstTimestamp ? 1 : -1));
}

View file

@ -91,6 +91,7 @@
<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>

View file

@ -251,6 +251,13 @@
<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>
@ -576,6 +583,19 @@
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;

View file

@ -0,0 +1,284 @@
<!--
/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 } 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 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',
});
}
</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>
</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;
gap: 0.75rem;
align-items: start;
margin-bottom: 0.5rem;
}
.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>