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>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-07-08 10:15:59 -04:00
committed by Will Pfleger
parent 2cc0eb5396
commit 5d82f93bc3
12 changed files with 1194 additions and 17 deletions

View File

@@ -15,7 +15,26 @@ export type TranscriptTurnSegment =
export type TranscriptDisplayBlock =
| { kind: "single"; item: TranscriptItem }
| { kind: "turn"; turnId: string; segments: TranscriptTurnSegment[] };
| { kind: "turn"; turnId: string; segments: TranscriptTurnSegment[] }
| {
/**
* Session boundary divider injected between consecutive session runs.
* `sessionId` is the id of the session that FOLLOWS the divider (the
* newer session in reading order).
*
* `labelState` encodes three distinct states:
* - `"current"` — newest-visible session AND matches the live relay
* session id. Agent is actively running this session.
* - `"most-recent"` — newest-visible session but no live session match
* (archived-only view or session ended). This is the
* most recently observed session — not current context.
* - `"earlier"` — an older session, not newest-visible.
*/
kind: "session-boundary";
sessionId: string;
sessionStartTimestamp: string;
labelState: "current" | "most-recent" | "earlier";
};
export type TranscriptToolRunChildSegment =
| { kind: "item"; item: TranscriptItem }
@@ -356,6 +375,34 @@ function getRenderClass(item: TranscriptItem) {
return item.renderClass ?? descriptor.renderClass;
}
/**
* Split a flat, time-ordered array of TranscriptItems into contiguous session
* runs. Items with a null sessionId are attributed to the most recently seen
* session (or a synthetic "unknown" run if no session has appeared yet).
*
* A new run begins whenever the sessionId changes to a distinct non-null value.
*/
function splitIntoSessionRuns(
items: TranscriptItem[],
): Array<{ sessionId: string; items: TranscriptItem[] }> {
const runs: Array<{ sessionId: string; items: TranscriptItem[] }> = [];
let currentRun: { sessionId: string; items: TranscriptItem[] } | null = null;
for (const item of items) {
const sid: string = item.sessionId ?? currentRun?.sessionId ?? "unknown";
if (
!currentRun ||
(item.sessionId && item.sessionId !== currentRun.sessionId)
) {
currentRun = { sessionId: sid, items: [] };
runs.push(currentRun);
}
currentRun.items.push(item);
}
return runs;
}
/**
* Build presentation-only display blocks from normalized transcript items.
* Raw observer order is preserved in the source items; this only reorders
@@ -365,9 +412,85 @@ function getRenderClass(item: TranscriptItem) {
* turnId=null. They are injected into the prompt segment of the first turn
* that follows them in stream order — placing System prompt between the user
* message bubble and the Prompt context sections in the rendered output.
*
* When items span multiple sessions (archived history + live session), a
* `session-boundary` block is injected between consecutive session runs.
* The newest-visible run is labeled distinctly when it equals
* `latestLiveSessionId`, signalling "current session" vs archived history.
* No boundary is emitted when only one session run is present.
*
* @param latestLiveSessionId - The session ID that is currently live on the
* relay (from `observerRelayStore`). Used to distinguish "current session"
* from "most recent observed session". Pass `null` when the relay is idle.
*/
export function buildTranscriptDisplayBlocks(
items: TranscriptItem[],
latestLiveSessionId: string | null = null,
): TranscriptDisplayBlock[] {
const sessionRuns = splitIntoSessionRuns(items);
// Fast path: single session (or zero) — no boundary blocks needed.
if (sessionRuns.length <= 1) {
return buildBlocksForRun(
sessionRuns[0]?.items ?? [],
/* isNewestRun */ true,
sessionRuns[0]?.sessionId ?? null,
latestLiveSessionId,
/* emitBoundary */ false,
);
}
// Multi-session: build blocks per run and interleave session-boundary blocks.
const allBlocks: TranscriptDisplayBlock[] = [];
for (let i = 0; i < sessionRuns.length; i++) {
const run = sessionRuns[i];
const isNewestRun = i === sessionRuns.length - 1;
// Inject boundary before this run's blocks (not before the oldest run).
if (i > 0) {
const firstItem = run.items.find((it) => it.timestamp);
const sessionStartTimestamp =
firstItem?.timestamp ?? new Date(0).toISOString();
const labelState: "current" | "most-recent" | "earlier" =
isNewestRun &&
latestLiveSessionId !== null &&
run.sessionId === latestLiveSessionId
? "current"
: isNewestRun
? "most-recent"
: "earlier";
allBlocks.push({
kind: "session-boundary",
sessionId: run.sessionId,
sessionStartTimestamp,
labelState,
});
}
const runBlocks = buildBlocksForRun(
run.items,
isNewestRun,
run.sessionId,
latestLiveSessionId,
/* emitBoundary */ false,
);
allBlocks.push(...runBlocks);
}
return allBlocks;
}
/**
* Build display blocks for a single session run's items.
* Internal helper — extracted so `buildTranscriptDisplayBlocks` can call it
* once per run without code duplication.
*/
function buildBlocksForRun(
items: TranscriptItem[],
_isNewestRun: boolean,
_sessionId: string | null,
_latestLiveSessionId: string | null,
_emitBoundary: boolean,
): TranscriptDisplayBlock[] {
const blocks: TranscriptDisplayBlock[] = [];
const turnBuckets = new Map<string, TurnBucket>();
@@ -469,6 +592,11 @@ export function flattenDisplayBlocks(
continue;
}
// session-boundary blocks carry no items — skip.
if (block.kind === "session-boundary") {
continue;
}
for (const segment of block.segments) {
if (segment.kind === "item") {
result.push(segment.item);