From 2fe95229537883b53c9ec9496bef1e9bb66eae7b Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 14 Apr 2026 23:21:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(ai):=20Workbench=20timeline=20=E2=80=94=20?= =?UTF-8?q?cross-module=20AI=20activity=20lens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/lib/data/ai/timeline/queries.test.ts | 82 +++++ .../web/src/lib/data/ai/timeline/queries.ts | 86 ++++++ .../src/routes/(app)/companion/+page.svelte | 1 + .../(app)/companion/missions/+page.svelte | 20 ++ .../(app)/companion/workbench/+page.svelte | 284 ++++++++++++++++++ 5 files changed, 473 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/data/ai/timeline/queries.test.ts create mode 100644 apps/mana/apps/web/src/lib/data/ai/timeline/queries.ts create mode 100644 apps/mana/apps/web/src/routes/(app)/companion/workbench/+page.svelte diff --git a/apps/mana/apps/web/src/lib/data/ai/timeline/queries.test.ts b/apps/mana/apps/web/src/lib/data/ai/timeline/queries.test.ts new file mode 100644 index 000000000..9e0607035 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/timeline/queries.test.ts @@ -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); + }); +}); diff --git a/apps/mana/apps/web/src/lib/data/ai/timeline/queries.ts b/apps/mana/apps/web/src/lib/data/ai/timeline/queries.ts new file mode 100644 index 000000000..99588b63c --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/timeline/queries.ts @@ -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(); + 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)); +} diff --git a/apps/mana/apps/web/src/routes/(app)/companion/+page.svelte b/apps/mana/apps/web/src/routes/(app)/companion/+page.svelte index f53435f20..5c1b18967 100644 --- a/apps/mana/apps/web/src/routes/(app)/companion/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/companion/+page.svelte @@ -91,6 +91,7 @@ diff --git a/apps/mana/apps/web/src/routes/(app)/companion/missions/+page.svelte b/apps/mana/apps/web/src/routes/(app)/companion/missions/+page.svelte index e3a9ebb46..13966b682 100644 --- a/apps/mana/apps/web/src/routes/(app)/companion/missions/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/companion/missions/+page.svelte @@ -251,6 +251,13 @@

{selected.title}

+ + Workbench → +