mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:41:08 +02:00
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:
parent
bf6b9cdd4b
commit
2fe9522953
5 changed files with 473 additions and 0 deletions
82
apps/mana/apps/web/src/lib/data/ai/timeline/queries.test.ts
Normal file
82
apps/mana/apps/web/src/lib/data/ai/timeline/queries.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
86
apps/mana/apps/web/src/lib/data/ai/timeline/queries.ts
Normal file
86
apps/mana/apps/web/src/lib/data/ai/timeline/queries.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue