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 15:02:44 -04:00
committed by Will Pfleger
co-authored by Will Pfleger
parent 2cc0eb5396
commit 5d82f93bc3
12 changed files with 1194 additions and 17 deletions
+114
View File
@@ -502,6 +502,120 @@ pub fn delete_save_subscription(
// ── read_archived_events ─────────────────────────────────────────────────────
// ── read_archived_observer_events_for_channel ────────────────────────────────
/// Read a paginated page of archived kind 24200 events scoped to one channel,
/// using the `observer_channel_index` as the primary lookup.
///
/// The index only contains rows with a known, non-null `channelId` (frames
/// pre-dating the channelId stamp, or rows where decryption failed, are
/// absent from the index and thus absent from every scoped channel view —
/// Will's (a) ruling, 2026-07-08).
///
/// Returns at most `limit` events (default `DEFAULT_READ_LIMIT`) in
/// newest-first order. Compound cursor `(before_created_at, before_id)` works
/// identically to `read_archived_events`.
#[tauri::command]
pub fn read_archived_observer_events_for_channel(
state: State<'_, AppState>,
channel_id: String,
before_created_at: Option<i64>,
before_id: Option<String>,
limit: Option<i64>,
) -> Result<Vec<String>, String> {
let identity_pk = identity_pubkey(&state)?;
let relay_url = relay_ws_url_with_override(&state);
let conn = open_db()?;
store::read_archived_observer_events_for_channel(
&conn,
&identity_pk,
&relay_url,
&channel_id,
before_created_at,
before_id.as_deref(),
limit.unwrap_or(DEFAULT_READ_LIMIT),
)
}
// ── index_observer_channel_id ─────────────────────────────────────────────────
/// Index one or more archived observer frame ids with their decoded channelId.
///
/// Called from the TS-side backfill after attempting `decryptObserverEvent`.
/// `channel_id` is `Some` for frames with a non-null channelId; `None` for
/// frames where decryption yielded no channelId or failed entirely. Both cases
/// write a status row so a re-run skips the frame (INSERT OR IGNORE on PK).
///
/// Idempotent: rows that are already indexed are left unchanged.
#[tauri::command]
pub fn index_observer_channel_id(
state: State<'_, AppState>,
entries: Vec<ObserverChannelIndexEntry>,
) -> Result<(), String> {
let identity_pk = identity_pubkey(&state)?;
let relay_url = relay_ws_url_with_override(&state);
let conn = open_db()?;
for entry in &entries {
store::upsert_observer_channel_index(
&conn,
&identity_pk,
&relay_url,
&entry.event_id,
entry.channel_id.as_deref(),
entry.created_at,
)?;
}
Ok(())
}
/// A single (event_id, channel_id?, created_at) record used by
/// `index_observer_channel_id`.
///
/// `channel_id` is `None` for frames where decryption found no channelId or
/// failed; those rows are written to `observer_channel_index` with a NULL
/// channel_id so the frame is treated as processed (no re-decrypt on re-run).
#[derive(Debug, Deserialize)]
pub struct ObserverChannelIndexEntry {
pub event_id: String,
pub channel_id: Option<String>,
pub created_at: i64,
}
// ── read_unindexed_observer_rows ─────────────────────────────────────────────
/// Return raw event JSON + id + created_at for all `owner_p` kind 24200 rows
/// that are NOT yet in `observer_channel_index`.
///
/// The TS-side backfill driver calls this once, decrypts each row, and sends
/// the (id, channelId, created_at) triples back via `index_observer_channel_id`.
/// Together these constitute the one-shot idempotent backfill required by the
/// Slice 1 acceptance criteria (Thufir Pass 4).
#[tauri::command]
pub fn read_unindexed_observer_rows(
state: State<'_, AppState>,
) -> Result<Vec<RawObserverRow>, String> {
let identity_pk = identity_pubkey(&state)?;
let relay_url = relay_ws_url_with_override(&state);
let conn = open_db()?;
let rows = store::read_unindexed_observer_rows(&conn, &identity_pk, &relay_url)?;
Ok(rows
.into_iter()
.map(|(id, raw_json, created_at)| RawObserverRow {
id,
raw_json,
created_at,
})
.collect())
}
/// Wire type returned by `read_unindexed_observer_rows`.
#[derive(Debug, Serialize)]
pub struct RawObserverRow {
pub id: String,
pub raw_json: String,
pub created_at: i64,
}
/// Default page size for `read_archived_events`.
const DEFAULT_READ_LIMIT: i64 = 50;
+20
View File
@@ -423,6 +423,26 @@ pub(super) fn commit_archive(
&p.matched_scope.scope_value,
now,
)?;
// Index at ingest: attempt to decrypt and extract channelId.
// Write a status row regardless of outcome so backfill never
// re-processes this frame (INSERT OR IGNORE on PK is a no-op if
// the row is already present from a prior run).
let channel_id_for_index: Option<String> =
buzz_core_pkg::observer::decrypt_observer_payload::<serde_json::Value>(
owner_keys, &p.event,
)
.ok()
.and_then(|v| v.get("channelId")?.as_str().map(|s| s.to_owned()));
store::upsert_observer_channel_index(
&tx,
identity_pk,
relay_url,
eid,
channel_id_for_index.as_deref(),
p.event.created_at.as_secs() as i64,
)?;
persisted += 1;
}
+205
View File
@@ -46,8 +46,62 @@ CREATE TABLE IF NOT EXISTS save_subscriptions (
created_at INTEGER NOT NULL,
PRIMARY KEY (identity_pubkey, relay_url, scope_type, scope_value)
);
-- Processing-status index for kind 24200 (observer) frames.
--
-- Every examined observer row — whether its channelId was successfully
-- decrypted or not — gets exactly one row here keyed by (identity, relay, id).
-- `channel_id` is nullable:
-- NOT NULL → frame was attributed to a channel; visible in that channel's feed.
-- NULL → frame was examined but had no channelId (pre-stamp or decrypt
-- failure); excluded from all scoped views per Will's (a) ruling
-- (2026-07-08), but the row exists so a re-run is a no-op.
--
-- Scoped reads filter `channel_id = ?`; NULL rows never match, staying hidden.
CREATE TABLE IF NOT EXISTS observer_channel_index (
identity_pubkey TEXT NOT NULL,
relay_url TEXT NOT NULL,
id TEXT NOT NULL,
channel_id TEXT,
created_at INTEGER NOT NULL,
PRIMARY KEY (identity_pubkey, relay_url, id)
);
CREATE INDEX IF NOT EXISTS idx_observer_channel
ON observer_channel_index (identity_pubkey, relay_url, channel_id, created_at DESC, id DESC);
-- One-row migration state table: tracks which idempotent migrations have run.
CREATE TABLE IF NOT EXISTS archive_migrations (
name TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
);
";
// ── Migration helpers ────────────────────────────────────────────────────────
/// Returns true if a named migration has already been applied.
pub fn migration_applied(conn: &Connection, name: &str) -> Result<bool, String> {
// The table is created in SCHEMA above, so it always exists after open.
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM archive_migrations WHERE name = ?1",
params![name],
|row| row.get(0),
)
.map_err(|e| format!("migration_applied query: {e}"))?;
Ok(count > 0)
}
/// Mark a named migration as applied (idempotent: no-op if already recorded).
pub fn mark_migration_applied(conn: &Connection, name: &str, now: i64) -> Result<(), String> {
conn.execute(
"INSERT INTO archive_migrations (name, applied_at) VALUES (?1, ?2)
ON CONFLICT (name) DO NOTHING",
params![name, now],
)
.map_err(|e| format!("mark_migration_applied: {e}"))?;
Ok(())
}
// ── Open / init ─────────────────────────────────────────────────────────────
/// Open (or create) the archive database at the given path.
@@ -568,6 +622,157 @@ pub fn read_archived_events(
.map_err(|e| format!("read read_archived_events row: {e}"))
}
/// Upsert a row in `observer_channel_index` for an examined kind 24200 frame.
///
/// `channel_id` is `Some` for frames with a successfully-decrypted, non-null
/// channelId; `None` for frames that had no channelId or where decryption
/// failed. Null-channel_id rows are excluded from all scoped channel reads
/// (scoped queries filter `channel_id = ?`; NULLs never match), satisfying
/// Will's (a) ruling (2026-07-08), while ensuring the row exists so a re-run
/// is a no-op.
///
/// Idempotent: if the (identity, relay, id) PK already exists the row is left
/// unchanged (INSERT OR IGNORE). Called both at ingest time (new frames) and
/// from the one-shot backfill for existing archived rows.
pub fn upsert_observer_channel_index(
conn: &Connection,
identity_pubkey: &str,
relay_url: &str,
event_id: &str,
channel_id: Option<&str>,
created_at: i64,
) -> Result<(), String> {
conn.execute(
"INSERT OR IGNORE INTO observer_channel_index
(identity_pubkey, relay_url, id, channel_id, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![identity_pubkey, relay_url, event_id, channel_id, created_at],
)
.map_err(|e| format!("failed to upsert observer_channel_index: {e}"))?;
Ok(())
}
/// Read all `owner_p` kind 24200 archived event rows (id + raw_json +
/// created_at) that have NOT yet been processed (i.e., have no row in
/// `observer_channel_index`, regardless of what channel_id would be).
///
/// Used by the one-shot backfill migration to discover which existing rows
/// still need channel attribution attempted. A row is "processed" as soon as
/// we attempt decryption — whether it succeeds (non-null channel_id written)
/// or fails (null channel_id written) — so re-runs skip those rows.
pub fn read_unindexed_observer_rows(
conn: &Connection,
identity_pubkey: &str,
relay_url: &str,
) -> Result<Vec<(String, String, i64)>, String> {
let mut stmt = conn
.prepare(
"SELECT ae.id, ae.raw_json, ae.created_at
FROM archived_events ae
INNER JOIN archived_event_scopes aes
ON aes.identity_pubkey = ae.identity_pubkey
AND aes.relay_url = ae.relay_url
AND aes.id = ae.id
WHERE ae.identity_pubkey = ?1
AND ae.relay_url = ?2
AND ae.kind = 24200
AND aes.scope_type = 'owner_p'
AND ae.id NOT IN (
SELECT id FROM observer_channel_index
WHERE identity_pubkey = ?1
AND relay_url = ?2
)
ORDER BY ae.created_at DESC, ae.id DESC",
)
.map_err(|e| format!("prepare read_unindexed_observer_rows: {e}"))?;
let rows = stmt
.query_map(params![identity_pubkey, relay_url], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
))
})
.map_err(|e| format!("query read_unindexed_observer_rows: {e}"))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("read read_unindexed_observer_rows row: {e}"))
}
/// Read a paginated page of archived kind 24200 events scoped to one channel,
/// using the `observer_channel_index` as the primary lookup.
///
/// Returns `raw_json` strings in newest-first order. The compound cursor
/// `(before_created_at, before_id)` works identically to `read_archived_events`.
/// A page shorter than `limit` signals the archive is exhausted for this channel.
#[allow(clippy::too_many_arguments)]
pub fn read_archived_observer_events_for_channel(
conn: &Connection,
identity_pubkey: &str,
relay_url: &str,
channel_id: &str,
before_created_at: Option<i64>,
before_id: Option<&str>,
limit: i64,
) -> Result<Vec<String>, String> {
let mut next_slot: usize = 4;
let mut extra_clauses = String::new();
let mut before_at_val: Option<i64> = None;
let mut before_id_val: Option<String> = None;
if let (Some(bat), Some(bid)) = (before_created_at, before_id) {
before_at_val = Some(bat);
before_id_val = Some(bid.to_owned());
extra_clauses.push_str(&format!(
" AND (oci.created_at < ?{next_slot} \
OR (oci.created_at = ?{next_slot} AND oci.id < ?{}))",
next_slot + 1,
));
next_slot += 2;
}
let limit_slot = next_slot;
let sql = format!(
"SELECT ae.raw_json \
FROM observer_channel_index oci \
INNER JOIN archived_events ae \
ON ae.identity_pubkey = oci.identity_pubkey \
AND ae.relay_url = oci.relay_url \
AND ae.id = oci.id \
WHERE oci.identity_pubkey = ?1 \
AND oci.relay_url = ?2 \
AND oci.channel_id = ?3\
{extra_clauses}\
ORDER BY oci.created_at DESC, oci.id DESC \
LIMIT ?{limit_slot}",
);
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(identity_pubkey.to_owned()),
Box::new(relay_url.to_owned()),
Box::new(channel_id.to_owned()),
];
if let (Some(bat), Some(bid)) = (before_at_val, before_id_val) {
params_vec.push(Box::new(bat));
params_vec.push(Box::new(bid));
}
params_vec.push(Box::new(limit));
let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("prepare read_archived_observer_events_for_channel: {e}"))?;
let rows = stmt
.query_map(param_refs.as_slice(), |row| row.get::<_, String>(0))
.map_err(|e| format!("query read_archived_observer_events_for_channel: {e}"))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("read read_archived_observer_events_for_channel row: {e}"))
}
/// GC: delete orphaned event rows whose last scope row was just removed.
///
/// Called after any batch deletion of scope rows. Uses a LEFT JOIN so only
@@ -728,3 +728,125 @@ fn test_read_archived_events_same_second_cursor_no_skip() {
// No overlap with page1.
assert!(page2.iter().all(|r| !r.contains("\"z\"")));
}
// ── observer_channel_index ───────────────────────────────────────────────────
#[test]
fn test_upsert_observer_channel_index_non_null_is_idempotent() {
let conn = in_memory();
let pk = "owner";
let relay = "wss://r";
upsert_observer_channel_index(&conn, pk, relay, "ev1", Some("ch-abc"), 1000).unwrap();
// Second call with same PK → INSERT OR IGNORE, no error, row unchanged.
upsert_observer_channel_index(&conn, pk, relay, "ev1", Some("ch-abc"), 2000).unwrap();
// Only one row should exist and created_at stays at 1000 (first write wins).
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM observer_channel_index WHERE id = 'ev1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1, "INSERT OR IGNORE must not duplicate rows");
let at: i64 = conn
.query_row(
"SELECT created_at FROM observer_channel_index WHERE id = 'ev1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(at, 1000, "first write's created_at must be preserved");
}
#[test]
fn test_upsert_observer_channel_index_null_channel_id_is_idempotent() {
let conn = in_memory();
let pk = "owner";
let relay = "wss://r";
// Write a NULL channel_id status row (unscoped / decrypt-failed frame).
upsert_observer_channel_index(&conn, pk, relay, "ev-null", None, 500).unwrap();
// Re-run → no-op.
upsert_observer_channel_index(&conn, pk, relay, "ev-null", None, 600).unwrap();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM observer_channel_index WHERE id = 'ev-null'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
count, 1,
"null-channel_id row must not be duplicated on re-run"
);
let channel_id: Option<String> = conn
.query_row(
"SELECT channel_id FROM observer_channel_index WHERE id = 'ev-null'",
[],
|r| r.get(0),
)
.unwrap();
assert!(
channel_id.is_none(),
"channel_id must be NULL for unscoped frames"
);
}
#[test]
fn test_read_archived_observer_excludes_null_channel_id_rows() {
// Scoped reads filter channel_id = '?'; NULL rows must never appear.
let conn = in_memory();
let pk = "owner";
let relay = "wss://r";
// Seed an archived event row.
upsert_archived_event(&conn, pk, relay, "ev-null", 24200, "agent", 1000, "{}", 0).unwrap();
upsert_event_scope(&conn, pk, relay, "ev-null", "owner_p", pk, 0).unwrap();
// Index it with a NULL channel_id.
upsert_observer_channel_index(&conn, pk, relay, "ev-null", None, 1000).unwrap();
// Scoped read for ANY channel must return nothing (NULL != 'some-channel').
let rows =
read_archived_observer_events_for_channel(&conn, pk, relay, "some-channel", None, None, 50)
.unwrap();
assert!(
rows.is_empty(),
"scoped read must not return null-channel_id rows"
);
}
#[test]
fn test_read_unindexed_observer_rows_excludes_processed_rows() {
// After a NULL channel_id row is written, the event must no longer appear
// in read_unindexed_observer_rows (it is "processed").
let conn = in_memory();
let pk = "owner";
let relay = "wss://r";
// Seed an archived observer event with owner_p scope.
upsert_archived_event(&conn, pk, relay, "ev-old", 24200, "agent", 1000, "{}", 0).unwrap();
upsert_event_scope(&conn, pk, relay, "ev-old", "owner_p", pk, 0).unwrap();
// Before indexing: ev-old must appear in unindexed rows.
let unindexed = read_unindexed_observer_rows(&conn, pk, relay).unwrap();
assert!(
unindexed.iter().any(|(id, _, _)| id == "ev-old"),
"ev-old must appear in unindexed rows before indexing"
);
// Index it with NULL channel_id (decrypt-failed / unscoped).
upsert_observer_channel_index(&conn, pk, relay, "ev-old", None, 1000).unwrap();
// After indexing: ev-old must NOT appear in unindexed rows (re-run is a no-op).
let unindexed2 = read_unindexed_observer_rows(&conn, pk, relay).unwrap();
assert!(
!unindexed2.iter().any(|(id, _, _)| id == "ev-old"),
"ev-old must be excluded from unindexed rows after null-channel_id indexing"
);
}
+3
View File
@@ -635,6 +635,9 @@ pub fn run() {
archive::list_save_subscriptions,
archive::delete_save_subscription,
archive::read_archived_events,
archive::read_archived_observer_events_for_channel,
archive::index_observer_channel_id,
archive::read_unindexed_observer_rows,
is_auto_update_supported,
])
.build(tauri::generate_context!())
@@ -41,6 +41,41 @@ const eventsByAgent = new Map<string, ObserverEvent[]>();
const transcriptByAgent = new Map<string, TranscriptState>();
const snapshotByAgent = new Map<string, ObserverSnapshot>();
// Per-agent, per-channel latest-live-session-id.
// Key: `${normalizePubkey(agentPubkey)}:${channelId}`.
// Set when a live relay observer event with a sessionId arrives.
// Cleared in resetAgentObserverStore.
//
// "Latest-live" means: the sessionId that most recently appeared via the
// live relay path (handleRelayObserverEvent). It is NOT derived from
// connectionState or an ever-live Set — an ever-live Set would incorrectly
// mark session A as "current" after session B has started (Thufir Pass 3).
//
// Stored as `{ sessionId, timestamp, seq }` so that late-arriving live frames
// from an older session never regress the latest-live id. We only advance when
// the parsed event sorts strictly AFTER the stored one, using the same
// two-key ordering as `compareObserverEvents`: timestamp first, then seq on a
// tie — so a higher-seq frame at equal timestamp still advances the entry.
type LatestLiveEntry = { sessionId: string; timestamp: string; seq: number };
const latestLiveSessionByAgentChannel = new Map<string, LatestLiveEntry>();
function liveSessionKey(agentPubkey: string, channelId: string | null): string {
return `${normalizePubkey(agentPubkey)}:${channelId ?? ""}`;
}
/** Read the latest-live-session-id for a (agent, channel) pair. */
export function getLatestLiveSessionId(
agentPubkey: string | null | undefined,
channelId: string | null | undefined,
): string | null {
if (!agentPubkey) return null;
return (
latestLiveSessionByAgentChannel.get(
liveSessionKey(agentPubkey, channelId ?? null),
)?.sessionId ?? null
);
}
// Per-agent listeners for `control_result` frames. The ModelPicker subscribes
// here to learn the async outcome of a `switch_model` frame (the send is
// fire-and-forget; the harness replies out-of-band over the observer relay).
@@ -184,6 +219,26 @@ export function compareObserverEvents(
return left.seq - right.seq;
}
/**
* Returns true if `candidate` sorts strictly after `stored` using the same
* two-key ordering as `compareObserverEvents`: later timestamp wins; equal
* timestamp falls back to higher seq. Extracted so latest-live advancement
* cannot drift from transcript ordering.
*/
export function isObserverEventAfter(
candidate: { timestamp: string; seq: number },
stored: { timestamp: string; seq: number },
): boolean {
const candidateTime = Date.parse(candidate.timestamp);
const storedTime = Date.parse(stored.timestamp);
if (Number.isFinite(candidateTime) && Number.isFinite(storedTime)) {
if (candidateTime !== storedTime) {
return candidateTime > storedTime;
}
}
return candidate.seq > stored.seq;
}
async function handleRelayObserverEvent(
event: RelayEvent,
activeGeneration: number,
@@ -211,6 +266,25 @@ async function handleRelayObserverEvent(
if (activeGeneration !== generation) {
return;
}
// Track the latest-live-session-id per (agent, channel) on the live path.
// Only set when the parsed event carries both a sessionId and channelId,
// so we never attribute a session to the wrong channel.
if (parsed.sessionId && parsed.channelId) {
const key = liveSessionKey(agentPubkey, parsed.channelId);
const stored = latestLiveSessionByAgentChannel.get(key);
// Advance only when this event sorts strictly AFTER the stored one via
// isObserverEventAfter (timestamp then seq — same ordering as
// compareObserverEvents). This prevents late-arriving live frames from
// older sessions from regressing the latest-live id, while also
// correctly advancing on a same-timestamp frame with a higher seq.
if (!stored || isObserverEventAfter(parsed, stored)) {
latestLiveSessionByAgentChannel.set(key, {
sessionId: parsed.sessionId,
timestamp: parsed.timestamp,
seq: parsed.seq,
});
}
}
appendAgentEvent(agentPubkey, parsed);
if (parsed.kind === "session_config_captured") {
void putAgentSessionConfig(agentPubkey, parsed.payload);
@@ -513,6 +587,7 @@ export function resetAgentObserverStore() {
snapshotByAgent.clear();
knownAgentPubkeys.clear();
knownAgentsBySubscription.clear();
latestLiveSessionByAgentChannel.clear();
onSessionConfigCaptured = null;
connectionState = "idle";
errorMessage = null;
@@ -1,11 +1,15 @@
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { CheckCheck, Radio } from "lucide-react";
import { CheckCheck, Clock, Radio } from "lucide-react";
import {
useActiveAgentTurns,
type ActiveTurnSummary,
} from "@/features/agents/activeAgentTurnsStore";
import {
subscribeAgentObserverStore,
getLatestLiveSessionId,
} from "@/features/agents/observerRelayStore";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll";
import { cn } from "@/shared/lib/cn";
@@ -134,9 +138,21 @@ export function AgentSessionTranscriptList({
() => isAgentTurnLive(activeTurns, channelId),
[activeTurns, channelId],
);
// Subscribe to the observer relay store so we read the latest-live-session-id
// reactively. We don't need the full snapshot — only the key for boundary labeling.
const getLatestLive = React.useCallback(
() => getLatestLiveSessionId(agentPubkey, channelId),
[agentPubkey, channelId],
);
const latestLiveSessionId = React.useSyncExternalStore(
subscribeAgentObserverStore,
getLatestLive,
);
const displayBlocks = React.useMemo(
() => buildTranscriptDisplayBlocks(items),
[items],
() => buildTranscriptDisplayBlocks(items, latestLiveSessionId),
[items, latestLiveSessionId],
);
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
@@ -282,6 +298,11 @@ function hasRenderableCompactBlock(block: TranscriptDisplayBlock) {
return isRenderableCompactItem(block.item);
}
// session-boundary dividers are not renderable content in compact view.
if (block.kind === "session-boundary") {
return false;
}
return block.segments.some((segment) => {
if (segment.kind === "item") {
return isRenderableCompactItem(segment.item);
@@ -316,6 +337,9 @@ function getDisplayBlockKey(block: TranscriptDisplayBlock) {
if (block.kind === "single") {
return block.item.id;
}
if (block.kind === "session-boundary") {
return `session-boundary:${block.sessionId}`;
}
return `turn:${block.turnId}`;
}
@@ -341,6 +365,15 @@ function TranscriptDisplayBlockView({
const hasCompletedInitialRenderRef = useHasCompletedInitialRender();
const animateSegmentEnter = animationPreferenceEnabled && !shouldReduceMotion;
if (block.kind === "session-boundary") {
return (
<SessionBoundaryDivider
labelState={block.labelState}
sessionStartTimestamp={block.sessionStartTimestamp}
/>
);
}
if (block.kind === "single") {
return (
<TranscriptItemRow
@@ -897,6 +930,51 @@ function TurnSetupStatus({
);
}
/**
* Horizontal rule rendered between session runs in the observer transcript.
*
* Three label states:
* - `"current"` — live session, agent is actively running in this context.
* - `"most-recent"` — newest visible session but no live match (archived-only
* or session ended). Shown as "Most recent observed session"
* so users know it is history, not active context.
* - `"earlier"` — an older session. Always labeled with the archive signal.
*/
function SessionBoundaryDivider({
labelState,
sessionStartTimestamp,
}: {
labelState: "current" | "most-recent" | "earlier";
sessionStartTimestamp: string;
}) {
const label =
labelState === "current"
? "Current session"
: labelState === "most-recent"
? "Most recent observed session — not in current context"
: "Earlier session — not in current context";
const formattedDate = new Date(sessionStartTimestamp).toLocaleString();
return (
<div
className="flex items-center gap-2 px-3 py-2"
data-testid="session-boundary-divider"
>
<div className="h-px flex-1 bg-border" />
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{labelState === "current" ? (
<Radio aria-hidden="true" className="h-3 w-3" />
) : (
<Clock aria-hidden="true" className="h-3 w-3" />
)}
{label}
{" · "}
{formattedDate}
</span>
<div className="h-px flex-1 bg-border" />
</div>
);
}
const TranscriptItemView = React.memo(function TranscriptItemView({
agentAvatarUrl,
agentName,
@@ -6,6 +6,7 @@ import {
flattenDisplayBlocks,
formatTurnSetupLabel,
} from "./agentSessionTranscriptGrouping.ts";
import { isObserverEventAfter } from "../observerRelayStore.ts";
const baseTimestamp = "2026-06-14T22:20:23.000Z";
@@ -639,3 +640,223 @@ function mkTool(id, label, renderClass = "generic", groupKey = label) {
channelId: "chan-1",
};
}
// ── Session-run splitting and session-boundary blocks ──────────────────────────
/**
* Build a minimal tool-call item stamped with a specific session.
*/
function sessionItem(id, sessionId, ts = "2026-07-08T00:00:00.000Z") {
return {
id,
type: "tool",
renderClass: "generic",
descriptor: {
renderClass: "generic",
label: id,
preview: id,
source: "harness",
groupKey: id,
},
title: id,
toolName: id,
buzzToolName: null,
status: "completed",
args: {},
result: "",
isError: false,
timestamp: ts,
startedAt: ts,
completedAt: ts,
turnId: `turn-${id}`,
sessionId,
channelId: "chan-1",
};
}
// ── Single session — no boundary injected ──────────────────────────────────────
test("buildTranscriptDisplayBlocks_singleSession_noBoundaryBlock", () => {
const items = [sessionItem("a", "sess-1"), sessionItem("b", "sess-1")];
const blocks = buildTranscriptDisplayBlocks(items);
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
assert.equal(
boundaryBlocks.length,
0,
"no session-boundary blocks for a single session",
);
});
// ── Two sessions — one boundary between them ───────────────────────────────────
test("buildTranscriptDisplayBlocks_twoSessions_oneBoundaryBetween", () => {
// items ordered oldest-first: sess-1 then sess-2
const items = [
sessionItem("a", "sess-1", "2026-07-08T00:00:01.000Z"),
sessionItem("b", "sess-2", "2026-07-08T00:00:02.000Z"),
];
const blocks = buildTranscriptDisplayBlocks(items);
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
assert.equal(
boundaryBlocks.length,
1,
"exactly one boundary for two sessions",
);
// The boundary is inserted BEFORE the newer run (sess-2).
const boundaryIndex = blocks.indexOf(boundaryBlocks[0]);
const prevBlock = blocks[boundaryIndex - 1];
const nextBlock = blocks[boundaryIndex + 1];
// Previous content block belongs to sess-1 items, next to sess-2.
const flatPrev = flattenDisplayBlocks([prevBlock]).map((i) => i.id);
const flatNext = flattenDisplayBlocks([nextBlock]).map((i) => i.id);
assert.ok(flatPrev.includes("a"), "content before boundary is sess-1");
assert.ok(flatNext.includes("b"), "content after boundary is sess-2");
});
// ── Newest session labeled correctly relative to latestLiveSessionId ──────────
test("buildTranscriptDisplayBlocks_newestMatchesLive_labelStateCurrent", () => {
const items = [
sessionItem("old", "sess-1", "2026-07-08T00:00:01.000Z"),
sessionItem("new", "sess-2", "2026-07-08T00:00:02.000Z"),
];
const blocks = buildTranscriptDisplayBlocks(items, "sess-2");
const boundary = blocks.find((b) => b.kind === "session-boundary");
assert.ok(boundary, "boundary present");
assert.equal(
boundary.labelState,
"current",
"labelState=current when newest session matches live id",
);
assert.equal(boundary.sessionId, "sess-2", "boundary sessionId is sess-2");
});
test("buildTranscriptDisplayBlocks_newestNoLive_labelStateMostRecent", () => {
const items = [
sessionItem("old", "sess-1", "2026-07-08T00:00:01.000Z"),
sessionItem("new", "sess-2", "2026-07-08T00:00:02.000Z"),
];
// latestLiveSessionId is null → newest session is "most-recent"
const blocksNoLive = buildTranscriptDisplayBlocks(items, null);
const boundaryNoLive = blocksNoLive.find(
(b) => b.kind === "session-boundary",
);
assert.equal(
boundaryNoLive.labelState,
"most-recent",
"labelState=most-recent when no live id (archived-only view)",
);
// latestLiveSessionId is a DIFFERENT session → newest is still "most-recent"
const blocksDiffLive = buildTranscriptDisplayBlocks(items, "sess-other");
const boundaryDiff = blocksDiffLive.find(
(b) => b.kind === "session-boundary",
);
assert.equal(
boundaryDiff.labelState,
"most-recent",
"labelState=most-recent when live id differs from newest visible",
);
});
// ── Three sessions — two boundaries ────────────────────────────────────────────
test("buildTranscriptDisplayBlocks_threeSessions_twoBoundaries", () => {
const items = [
sessionItem("a", "sess-1", "2026-07-08T00:00:01.000Z"),
sessionItem("b", "sess-2", "2026-07-08T00:00:02.000Z"),
sessionItem("c", "sess-3", "2026-07-08T00:00:03.000Z"),
];
const blocks = buildTranscriptDisplayBlocks(items);
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
assert.equal(boundaryBlocks.length, 2, "two boundaries for three sessions");
// With no live id: newest (sess-3) boundary = "most-recent"; older = "earlier".
const newestBoundary = boundaryBlocks[boundaryBlocks.length - 1];
const olderBoundary = boundaryBlocks[0];
assert.equal(
newestBoundary.labelState,
"most-recent",
"newest boundary is most-recent when latestLiveSessionId is null",
);
assert.equal(
olderBoundary.labelState,
"earlier",
"older boundary is earlier when latestLiveSessionId is null",
);
});
// ── Null sessionId items stay in the current run ───────────────────────────────
test("buildTranscriptDisplayBlocks_nullSessionId_staysInCurrentRun", () => {
// An item with null sessionId should not start a new run.
const items = [
sessionItem("a", "sess-1", "2026-07-08T00:00:01.000Z"),
// null sessionId — stays in sess-1 run
{ ...sessionItem("b", null, "2026-07-08T00:00:02.000Z"), sessionId: null },
sessionItem("c", "sess-1", "2026-07-08T00:00:03.000Z"),
];
const blocks = buildTranscriptDisplayBlocks(items);
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
assert.equal(
boundaryBlocks.length,
0,
"no boundary when null-sessionId items are present within a single session",
);
});
// ── flattenDisplayBlocks skips session-boundary blocks ────────────────────────
test("flattenDisplayBlocks_skipsSessionBoundaryBlocks", () => {
const items = [
sessionItem("a", "sess-1", "2026-07-08T00:00:01.000Z"),
sessionItem("b", "sess-2", "2026-07-08T00:00:02.000Z"),
];
const blocks = buildTranscriptDisplayBlocks(items);
assert.ok(
blocks.some((b) => b.kind === "session-boundary"),
"test setup: boundary must be present",
);
const flat = flattenDisplayBlocks(blocks);
const ids = flat.map((i) => i.id);
assert.ok(ids.includes("a"), "item a is in flattened output");
assert.ok(ids.includes("b"), "item b is in flattened output");
assert.equal(
flat.filter((i) => i.kind === "session-boundary").length,
0,
"session-boundary items are excluded from flatten",
);
});
// ── isObserverEventAfter — latest-live ordering ──────────────────────────────
test("isObserverEventAfter returns true when candidate has later timestamp", () => {
const stored = { timestamp: "2026-07-08T00:00:01.000Z", seq: 5 };
const candidate = { timestamp: "2026-07-08T00:00:02.000Z", seq: 1 };
assert.ok(isObserverEventAfter(candidate, stored));
});
test("isObserverEventAfter returns false when candidate has earlier timestamp", () => {
const stored = { timestamp: "2026-07-08T00:00:02.000Z", seq: 5 };
const candidate = { timestamp: "2026-07-08T00:00:01.000Z", seq: 10 };
assert.ok(!isObserverEventAfter(candidate, stored));
});
test("isObserverEventAfter returns true for same timestamp, higher seq — session B advances over session A", () => {
// This is the tiebreak case: timestamp equal, seq tiebreak must mirror
// compareObserverEvents so latest-live never drifts from transcript order.
const stored = { timestamp: "2026-07-08T00:00:01.000Z", seq: 3 };
const candidate = { timestamp: "2026-07-08T00:00:01.000Z", seq: 7 };
assert.ok(isObserverEventAfter(candidate, stored));
});
test("isObserverEventAfter returns false for same timestamp, same seq", () => {
const stored = { timestamp: "2026-07-08T00:00:01.000Z", seq: 3 };
const candidate = { timestamp: "2026-07-08T00:00:01.000Z", seq: 3 };
assert.ok(!isObserverEventAfter(candidate, stored));
});
test("isObserverEventAfter returns false for same timestamp, lower seq", () => {
const stored = { timestamp: "2026-07-08T00:00:01.000Z", seq: 7 };
const candidate = { timestamp: "2026-07-08T00:00:01.000Z", seq: 3 };
assert.ok(!isObserverEventAfter(candidate, stored));
});
@@ -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);
@@ -9,10 +9,14 @@ import {
} from "@/features/agents/observerRelayStore";
import {
listSaveSubscriptions,
readArchivedEvents,
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
@@ -55,16 +59,25 @@ export function useAgentTranscript(
const ARCHIVED_EVENTS_PAGE_SIZE = 50;
/**
* Load-older-on-scroll for archived observer frames.
* Load-older-on-scroll for archived observer frames, scoped to a single channel.
*
* Checks whether an `owner_p` save subscription exists for the current
* identity. If one does, exposes `fetchOlderArchived` and `hasOlderArchived`
* for wiring into a sentinel-based scroll loader.
* 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).
*
* Degrades cleanly when no subscription exists (returns `hasOlderArchived:
* false` without making any archive calls).
* 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) {
export function useLoadArchivedObserverEvents(
enabled: boolean,
channelId: string | null,
) {
const identityQuery = useIdentityQuery();
const identityPubkey = identityQuery.data?.pubkey ?? null;
@@ -74,6 +87,22 @@ export function useLoadArchivedObserverEvents(enabled: boolean) {
);
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.
@@ -98,12 +127,18 @@ export function useLoadArchivedObserverEvents(enabled: boolean) {
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 () => {
@@ -111,22 +146,112 @@ export function useLoadArchivedObserverEvents(enabled: boolean) {
};
}, [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 readArchivedEvents("owner_p", identityPubkey, {
kinds: [24200],
const events = await readArchivedObserverEventsForChannel(channelId, {
before: before ?? null,
limit: ARCHIVED_EVENTS_PAGE_SIZE,
});
@@ -143,7 +268,7 @@ export function useLoadArchivedObserverEvents(enabled: boolean) {
await ingestArchivedObserverEvents(events);
}
// A short page means the archive is exhausted.
// A short page means the archive is exhausted for this channel.
if (events.length < ARCHIVED_EVENTS_PAGE_SIZE) {
setHasOlderArchived(false);
}
@@ -152,7 +277,7 @@ export function useLoadArchivedObserverEvents(enabled: boolean) {
} finally {
isFetchingRef.current = false;
}
}, [enabled, identityPubkey, hasSubscription, hasOlderArchived]);
}, [enabled, identityPubkey, hasSubscription, channelId, hasOlderArchived]);
return { fetchOlderArchived, hasOlderArchived };
}
@@ -131,7 +131,7 @@ export function AgentSessionThreadPanel({
: `Last updated ${new Date(latestActivityAt).toLocaleString()}`;
const { fetchOlderArchived, hasOlderArchived } =
useLoadArchivedObserverEvents(isLive);
useLoadArchivedObserverEvents(isLive, sessionChannelId ?? null);
useLoadOlderOnScroll({
fetchOlder: fetchOlderArchived,
+86
View File
@@ -220,6 +220,92 @@ export async function archiveEvents(
});
}
/**
* Read a paginated page of archived kind 24200 (observer) events for a
* specific channel, using the `observer_channel_index`.
*
* Only returns frames whose `channelId` was successfully decrypted and
* matched this channel. Frames with null/decrypt-failed channelId are
* excluded (Will's (a) ruling). Compound cursor + short-page exhaustion
* signal work identically to `readArchivedEvents`.
*/
export async function readArchivedObserverEventsForChannel(
channelId: string,
opts?: {
before?: { createdAt: number; id: string } | null;
limit?: number;
},
): Promise<import("@/shared/api/types").RelayEvent[]> {
const rawRows = await invokeTauri<string[]>(
"read_archived_observer_events_for_channel",
{
channelId,
beforeCreatedAt: opts?.before?.createdAt ?? null,
beforeId: opts?.before?.id ?? null,
limit: opts?.limit ?? null,
},
);
return rawRows
.map((raw) => {
try {
return JSON.parse(raw) as import("@/shared/api/types").RelayEvent;
} catch {
console.warn(
"[tauriArchive] failed to parse archived observer raw_json:",
raw,
);
return null;
}
})
.filter((e): e is import("@/shared/api/types").RelayEvent => e !== null);
}
/**
* Index one or more archived observer frames by channelId.
*
* `channelId` is nullable: pass `null` for frames that are unscoped,
* malformed, or whose payload could not be decrypted. Null rows are written
* as a processed-state marker so re-runs skip them; they are never returned
* by channel-scoped reads (which filter `channel_id = ?`).
*
* Idempotent — already-indexed frames are silently skipped.
*/
export async function indexObserverChannelId(
entries: Array<{
eventId: string;
channelId: string | null;
createdAt: number;
}>,
): Promise<void> {
if (entries.length === 0) return;
await invokeTauri("index_observer_channel_id", {
entries: entries.map((e) => ({
event_id: e.eventId,
channel_id: e.channelId,
created_at: e.createdAt,
})),
});
}
/**
* Return all `owner_p` kind 24200 archived event rows not yet indexed.
*
* Used by the one-shot backfill driver. Returns raw Nostr event JSON plus
* event id and created_at for each row so the caller can decrypt and index.
*/
export async function readUnindexedObserverRows(): Promise<
Array<{ id: string; rawJson: string; createdAt: number }>
> {
const rows = await invokeTauri<
Array<{ id: string; raw_json: string; created_at: number }>
>("read_unindexed_observer_rows");
return rows.map((r) => ({
id: r.id,
rawJson: r.raw_json,
createdAt: r.created_at,
}));
}
/**
* Read a paginated page of archived raw events for a scope.
*