Files
buzz/desktop/src/features/agents/ui/useObserverEvents.ts
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 5d82f93bc3 fix(desktop): scope observer feed by channel and add session-boundary dividers
Two observer-feed defects fixed in a single PR:
Slice 1 — channel-scoped archive (index at ingest + idempotent backfill)
The archive read path queried all owner_p kind 24200 rows for an identity,
then relied on a display-time scopeByChannel filter. When the filter was
missing or skipped, frames from other channels leaked into the current
channel's observer feed.
Fix: add an observer_channel_index table that maps (identity, relay, event_id)
to channel_id. Three new Tauri commands drive this:
- read_archived_observer_events_for_channel: channel-scoped paginated read via
  the index (replaces the raw owner_p scan in useLoadArchivedObserverEvents)
- index_observer_channel_id: TS calls this after decrypting events to write
  index entries
- read_unindexed_observer_rows: returns all owner_p kind 24200 rows not yet
  indexed, for the one-shot idempotent backfill
Backfill strategy (choice i, backfill-before-read): a React.useEffect in
useLoadArchivedObserverEvents runs once on mount, reads all unindexed rows,
decrypts each, and batch-writes index entries. After backfill the channel
index is authoritative — the scoped read pages it directly with no zero-match
raw-page guard needed.
Null/decrypt-failed channelId rows stay unscoped and are hidden from every
scoped channel view (Will's ruling: option (a) — display decision only, rows
stay on disk).
Slice 2 — session-boundary rendering
Items rendered flat with no session grouping; archived and live frames
interleaved without any visual marker of where one agent session ended and
another began, causing users to think archived historical context is still
live.
Fix: split TranscriptItem[] into contiguous session runs before calling
buildTranscriptDisplayBlocks. This also fixes the pendingSystemPrompt
single-slot clobber (each run gets its own lifecycle). A new session-boundary
display block kind is interleaved between runs; SessionBoundaryDivider renders
a horizontal rule with session start time. No boundary emitted for
single-session transcripts.
Liveness — "current session" gating
Track latest-live-session-id per (normalized agent pubkey, channelId) in
observerRelayStore, set on the handleRelayObserverEvent path, cleared in
resetAgentObserverStore. Label a boundary "current session" only when it is
both the newest-visible session AND equals that latest-live id; otherwise label
it "most recent observed session".

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-07-08 15:02:44 -04:00

284 lines
9.6 KiB
TypeScript

import * as React from "react";
import {
ensureRelayObserverSubscription,
getAgentObserverSnapshot,
getAgentTranscript,
ingestArchivedObserverEvents,
subscribeAgentObserverStore,
} from "@/features/agents/observerRelayStore";
import {
listSaveSubscriptions,
readArchivedObserverEventsForChannel,
readUnindexedObserverRows,
indexObserverChannelId,
} from "@/shared/api/tauriArchive";
import { decryptObserverEvent } from "@/shared/api/tauriObserver";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { TranscriptItem } from "./agentSessionTypes";
import type { RelayEvent } from "@/shared/api/types";
// Stable subscribe reference shared by all useSyncExternalStore hooks.
// subscribeAgentObserverStore already has a fixed identity, so this thin
// wrapper satisfies React's requirement without per-hook useCallback.
const subscribeToStore = (onStoreChange: () => void) =>
subscribeAgentObserverStore(onStoreChange);
export function useObserverEvents(
enabled: boolean,
agentPubkey?: string | null,
) {
const getSnapshot = React.useCallback(
() => getAgentObserverSnapshot(agentPubkey, enabled),
[agentPubkey, enabled],
);
const snapshot = React.useSyncExternalStore(subscribeToStore, getSnapshot);
React.useEffect(() => {
if (enabled && agentPubkey) {
void ensureRelayObserverSubscription();
}
}, [enabled, agentPubkey]);
return snapshot;
}
export function useAgentTranscript(
enabled: boolean,
agentPubkey?: string | null,
): TranscriptItem[] {
const getSnapshot = React.useCallback(
() => getAgentTranscript(agentPubkey, enabled),
[agentPubkey, enabled],
);
return React.useSyncExternalStore(subscribeToStore, getSnapshot);
}
const ARCHIVED_EVENTS_PAGE_SIZE = 50;
/**
* Load-older-on-scroll for archived observer frames, scoped to a single channel.
*
* Reads from `observer_channel_index` (via `readArchivedObserverEventsForChannel`)
* so only frames attributable to this channel are loaded — cross-channel
* contamination is impossible. Frames with null/decrypt-failed channelId are
* excluded at the Rust level (Will's (a) ruling).
*
* On first mount, runs a one-shot idempotent backfill: decrypts all
* not-yet-indexed `owner_p` kind 24200 rows and writes their (id, channelId)
* pairs into the index, so existing archived history is available immediately
* without requiring the user to scroll through every page.
*
* Degrades cleanly when no `owner_p` subscription exists or when `channelId`
* is null (returns `hasOlderArchived: false` without making any archive calls).
*/
export function useLoadArchivedObserverEvents(
enabled: boolean,
channelId: string | null,
) {
const identityQuery = useIdentityQuery();
const identityPubkey = identityQuery.data?.pubkey ?? null;
// Whether the current identity has an owner_p save subscription.
const [hasSubscription, setHasSubscription] = React.useState<boolean | null>(
null,
);
const [hasOlderArchived, setHasOlderArchived] = React.useState(true);
const isFetchingRef = React.useRef(false);
// Backfill state: "pending" → "running" → "done".
// fetchOlderArchived awaits backfillPromiseRef before reading the index so
// the first scroll-trigger never races the write path and incorrectly marks
// the channel exhausted before backfill has completed.
const backfillStatusRef = React.useRef<"pending" | "running" | "done">(
"pending",
);
const backfillPromiseRef = React.useRef<Promise<void> | null>(null);
const backfillResolveRef = React.useRef<(() => void) | null>(null);
// Expose a promise that resolves when backfill is done. Created eagerly so
// fetchOlderArchived can await it before the effect that starts backfill fires.
if (!backfillPromiseRef.current) {
backfillPromiseRef.current = new Promise<void>((resolve) => {
backfillResolveRef.current = resolve;
});
}
// Compound keyset cursor: tracks both `created_at` and `id` of the oldest
// event seen so far. Mirrors the SQL `ORDER BY created_at DESC, id DESC` so
// same-second siblings are never skipped at a page boundary.
const cursorRef = React.useRef<{ createdAt: number; id: string } | null>(
null,
);
// Check for an owner_p subscription once per identity.
React.useEffect(() => {
if (!enabled || !identityPubkey) {
return;
}
let cancelled = false;
listSaveSubscriptions()
.then((subs) => {
if (cancelled) {
return;
}
const hasSub = subs.some(
(s) => s.scopeType === "owner_p" && s.scopeValue === identityPubkey,
);
setHasSubscription(hasSub);
if (!hasSub) {
setHasOlderArchived(false);
// No subscription → backfill will never run; resolve the promise
// immediately so fetchOlderArchived doesn't await indefinitely.
backfillStatusRef.current = "done";
backfillResolveRef.current?.();
}
})
.catch(() => {
if (!cancelled) {
setHasSubscription(false);
setHasOlderArchived(false);
backfillStatusRef.current = "done";
backfillResolveRef.current?.();
}
});
return () => {
cancelled = true;
};
}, [enabled, identityPubkey]);
// One-shot idempotent backfill: attempt to decrypt all not-yet-processed
// owner_p kind 24200 rows and write their (id, channelId?) into
// observer_channel_index. A status row is written for EVERY processed event —
// null/failed channelId rows get channel_id=null, so re-runs skip them.
// Runs once per mount when the subscription is confirmed; gated by
// backfillStatusRef so fetchOlderArchived can await completion.
React.useEffect(() => {
if (
!enabled ||
!hasSubscription ||
backfillStatusRef.current !== "pending"
) {
return;
}
backfillStatusRef.current = "running";
const promise = (async () => {
try {
const rows = await readUnindexedObserverRows();
const toIndex: Array<{
eventId: string;
channelId: string | null;
createdAt: number;
}> = [];
for (const row of rows) {
let parsed: RelayEvent;
try {
parsed = JSON.parse(row.rawJson) as RelayEvent;
} catch {
// Malformed JSON: write a null status row so we skip on re-run.
toIndex.push({
eventId: row.id,
channelId: null,
createdAt: row.createdAt,
});
continue;
}
try {
const decoded = (await decryptObserverEvent(parsed)) as {
channelId?: string | null;
};
// Write a status row for every event — non-null channelId is
// attributable; null/undefined channelId writes channel_id=null so
// the frame is marked processed and excluded from scoped views.
toIndex.push({
eventId: row.id,
channelId: decoded?.channelId ?? null,
createdAt: row.createdAt,
});
} catch {
// Decrypt failure → write null status row (processed, unscoped).
toIndex.push({
eventId: row.id,
channelId: null,
createdAt: row.createdAt,
});
}
}
if (toIndex.length > 0) {
await indexObserverChannelId(toIndex);
}
} catch (error) {
console.error(
"[useLoadArchivedObserverEvents] backfill failed:",
error,
);
} finally {
backfillStatusRef.current = "done";
backfillResolveRef.current?.();
}
})();
backfillPromiseRef.current = promise;
}, [enabled, hasSubscription]);
const fetchOlderArchived = React.useCallback(async () => {
if (
!enabled ||
!identityPubkey ||
!hasSubscription ||
!channelId ||
isFetchingRef.current ||
!hasOlderArchived
) {
return;
}
// Await backfill completion before reading the channel index. This
// guarantees the index is populated before the first paginated read, so
// a scroll-trigger that fires before backfill writes can't return 0 rows
// and falsely mark the channel exhausted.
if (backfillPromiseRef.current) {
await backfillPromiseRef.current;
}
// Re-check after awaiting: hasOlderArchived might have been set false
// while we were waiting (e.g. subscription check failed).
if (!hasOlderArchived) {
return;
}
isFetchingRef.current = true;
try {
const before = cursorRef.current ?? undefined;
const events = await readArchivedObserverEventsForChannel(channelId, {
before: before ?? null,
limit: ARCHIVED_EVENTS_PAGE_SIZE,
});
if (events.length > 0) {
// Cursor = the last row in newest-first order = the oldest event on
// this page. Capture both created_at and id to mirror the compound
// sort key so same-second siblings are not skipped on the next page.
const oldestEvent = events[events.length - 1];
cursorRef.current = {
createdAt: oldestEvent.created_at,
id: oldestEvent.id,
};
await ingestArchivedObserverEvents(events);
}
// A short page means the archive is exhausted for this channel.
if (events.length < ARCHIVED_EVENTS_PAGE_SIZE) {
setHasOlderArchived(false);
}
} catch (error) {
console.error("[useLoadArchivedObserverEvents] fetch failed:", error);
} finally {
isFetchingRef.current = false;
}
}, [enabled, identityPubkey, hasSubscription, channelId, hasOlderArchived]);
return { fetchOlderArchived, hasOlderArchived };
}