mirror of
https://github.com/block/buzz.git
synced 2026-11-08 18:31:56 +00:00
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:
committed by
Will Pfleger
parent
2cc0eb5396
commit
5d82f93bc3
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user