Files
buzz/desktop/src/features/agents/ui/useObserverEvents.ts
Will Pfleger 8cb05028be fix(observer): eager archive hydration on panel open + 200-frame pages (#2574)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-07-23 15:40:46 -04:00

410 lines
16 KiB
TypeScript

import * as React from "react";
import {
ensureRelayObserverSubscription,
getAgentObserverSnapshot,
getAgentTranscript,
getArchivedChannelEvents,
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 { ObserverEvent, TranscriptItem } from "./agentSessionTypes";
import type { RelayEvent } from "@/shared/api/types";
import {
createArchivePagingState,
applyChannelReset,
runHydrationLoop,
} from "./archivePagingState";
export type { ArchivePagingState } from "./archivePagingState";
// 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);
}
/**
* Reactively read the channel-scoped archive raw events for a given
* (agent, channel) pair. Returns an empty array until archive pages are loaded.
*
* Subscribes to `subscribeAgentObserverStore` so it re-renders whenever
* `ingestArchivedObserverEvents` writes new pages to the archive window — the
* same subscription used by the live event snapshot, keeping both in sync.
*
* UI consumers merge these events with the live event window and call
* `buildTranscriptState()` once over the combined sorted/deduplicated set,
* so stateful transcript relationships (tool start/update, plan replacement,
* permission request/response) are never split across two independent state
* machines.
*/
export function useArchivedChannelEvents(
agentPubkey: string | null | undefined,
channelId: string | null | undefined,
): ObserverEvent[] {
const getSnapshot = React.useCallback(
() => getArchivedChannelEvents(agentPubkey, channelId),
[agentPubkey, channelId],
);
return React.useSyncExternalStore(subscribeToStore, getSnapshot);
}
const ARCHIVED_EVENTS_PAGE_SIZE = 200;
// Number of pages to load eagerly on panel open (before any scroll). Each page
// is ARCHIVED_EVENTS_PAGE_SIZE frames; 10 pages = 2000 frames, which covers
// agent turns that emit hundreds of frames (e.g. a full code-review turn ~900).
const INITIAL_HYDRATION_BUDGET_PAGES = 10;
/**
* 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;
// All mutable paging state lives in one stable ref. createArchivePagingState
// initialises the backfill promise eagerly so fetchOlderArchived can await it
// before the backfill effect fires. applyChannelReset resets cursor/exhaustion/
// fetchLock when channelId changes; backfill state is untouched (identity-level).
// Lazy init via nullable ref avoids creating a discarded ArchivePagingState
// (including a throwaway pending Promise) on every render after the first.
const pagingStateRef = React.useRef<ReturnType<
typeof createArchivePagingState
> | null>(null);
if (!pagingStateRef.current) {
pagingStateRef.current = createArchivePagingState();
}
const ps = pagingStateRef.current;
// React state mirrors the fields callers observe so re-renders fire on change.
const [hasSubscription, setHasSubscription] = React.useState<boolean | null>(
ps.hasSubscription,
);
const [hasOlderArchived, setHasOlderArchived] = React.useState(
ps.hasOlderArchived,
);
// Reset per-channel paging state when channelId changes. Backfill state is
// identity-level (not per-channel) and must NOT be reset here — the backfill
// index covers all channels and only needs to run once per identity mount.
// Only the cursor, exhaustion flag, fetching lock, channel label, and
// resetGeneration are channel-scoped. resetGeneration is incremented by
// applyChannelReset so in-flight reads from any prior reset (including
// A→B→A) detect staleness and discard their results.
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the intentional reset key; ps is a stable ref excluded from deps by convention; setHasOlderArchived is a stable React state setter
React.useEffect(() => {
applyChannelReset(ps, channelId);
setHasOlderArchived(true);
}, [channelId]);
// Check for an owner_p subscription once per identity.
// biome-ignore lint/correctness/useExhaustiveDependencies: ps is a stable ref excluded from deps by convention; setHasSubscription/setHasOlderArchived are stable React state setters
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);
ps.hasSubscription = hasSub;
if (!hasSub) {
setHasOlderArchived(false);
ps.hasOlderArchived = false;
// No subscription → backfill will never run; resolve the promise
// immediately so fetchOlderArchived doesn't await indefinitely.
ps.backfillStatus = "done";
ps.backfillResolve?.();
}
})
.catch(() => {
if (!cancelled) {
setHasSubscription(false);
ps.hasSubscription = false;
setHasOlderArchived(false);
ps.hasOlderArchived = false;
ps.backfillStatus = "done";
ps.backfillResolve?.();
}
});
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
// ps.backfillStatus so fetchOlderArchived can await completion.
// biome-ignore lint/correctness/useExhaustiveDependencies: ps is a stable ref excluded from deps by convention
React.useEffect(() => {
if (!enabled || !hasSubscription || ps.backfillStatus !== "pending") {
return;
}
ps.backfillStatus = "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 {
ps.backfillStatus = "done";
ps.backfillResolve?.();
}
})();
ps.backfillPromise = promise;
}, [enabled, hasSubscription]);
// biome-ignore lint/correctness/useExhaustiveDependencies: ps is a stable ref; all per-page state (isFetching, cursor, hasOlderArchived, resetGeneration) is read from the ref rather than React state, so this callback is intentionally stable across exhaustion/channel changes
const fetchOlderArchived = React.useCallback(async () => {
if (
!enabled ||
!identityPubkey ||
!hasSubscription ||
!channelId ||
ps.isFetching ||
!ps.hasOlderArchived
) {
return;
}
// Snapshot the reset generation at the start of this request. Every
// shared-state write below rechecks requestGeneration === ps.resetGeneration
// first. A mismatch means at least one channel switch occurred while we
// were awaiting async I/O — even A→B→A is detected because each switch
// increments resetGeneration. Channel ID is kept only as the query input.
const requestGeneration = ps.resetGeneration;
// 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 (ps.backfillPromise) {
await ps.backfillPromise;
}
// Re-check after awaiting: generation may have advanced (channel switched),
// archive exhausted, or another concurrent caller may have acquired the
// fetch lock while we were suspended on backfill. All three must be
// re-evaluated because any of them could have changed mid-await.
if (
!ps.hasOlderArchived ||
requestGeneration !== ps.resetGeneration ||
ps.isFetching
) {
return;
}
// Acquire the fetch lock under this request's generation. The finally
// block only releases the lock if the generation still matches — so a stale
// in-flight request cannot clear the lock that belongs to a later reset.
ps.isFetching = true;
try {
const before = ps.cursor ?? undefined;
const events = await readArchivedObserverEventsForChannel(channelId, {
before: before ?? null,
limit: ARCHIVED_EVENTS_PAGE_SIZE,
});
// Discard result if the generation advanced while the Tauri read was in
// flight (channel switch, including A→B→A). The new channel will start
// its own read with a null cursor.
if (requestGeneration !== ps.resetGeneration) {
return;
}
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];
ps.cursor = {
createdAt: oldestEvent.created_at,
id: oldestEvent.id,
};
await ingestArchivedObserverEvents(events);
}
// Re-check generation after ingestArchivedObserverEvents: ingestion
// decrypts each frame asynchronously and may take time. If a channel
// switch occurred during that await (including A→B→A), discard all
// remaining shared-state writes — exhaustion and React mirror.
if (requestGeneration !== ps.resetGeneration) {
return;
}
// A short page means the archive is exhausted for this channel.
if (events.length < ARCHIVED_EVENTS_PAGE_SIZE) {
setHasOlderArchived(false);
ps.hasOlderArchived = false;
}
} catch (error) {
console.error("[useLoadArchivedObserverEvents] fetch failed:", error);
} finally {
// Only release the fetch lock if this request still owns it. If the
// generation advanced (any channel switch including A→B→A), the new
// channel acquired its own lock — releasing here would steal it.
if (requestGeneration === ps.resetGeneration) {
ps.isFetching = false;
}
}
}, [enabled, identityPubkey, hasSubscription, channelId]);
// Eager initial hydration: on panel open (or channel switch), load archive
// pages automatically until the budget is reached or the channel is exhausted.
// This makes archived history visible immediately without any scrolling.
//
// Runs when: enabled + subscription confirmed + channelId resolved +
// hydration not yet done for this channel. Respects `applyChannelReset`
// (which resets initialHydrationDone) so channel switches trigger a fresh
// pass. Uses fetchOlderArchived's existing lock/cursor/backfill-await
// machinery — no parallel state machine.
//
// fetchOlderArchived is now stable (it no longer captures hasOlderArchived
// from React state — it reads ps.hasOlderArchived from the ref), so it is
// safe to call from this effect without coupling the hydration lifecycle to
// React state identity changes.
// biome-ignore lint/correctness/useExhaustiveDependencies: ps is a stable ref; initialHydrationDone is read from ps (not as a reactive dep) to avoid triggering re-runs; fetchOlderArchived is stable and intentionally omitted
React.useEffect(() => {
if (
!enabled ||
!identityPubkey ||
!hasSubscription ||
!channelId ||
ps.initialHydrationDone
) {
return;
}
// Mark done immediately to prevent concurrent hydration loops. The loop
// runs asynchronously; the signal object handles mid-loop cancellation on
// channel switch (the cleanup fn sets signal.cancelled = true).
ps.initialHydrationDone = true;
const signal = { cancelled: false };
void runHydrationLoop(
ps,
fetchOlderArchived,
INITIAL_HYDRATION_BUDGET_PAGES,
signal,
);
return () => {
signal.cancelled = true;
};
}, [enabled, identityPubkey, hasSubscription, channelId]);
return { fetchOlderArchived, hasOlderArchived };
}