From 06e8be9098f099ff9036cfbe637390d5ac982809 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 20 Aug 2026 17:13:47 -0600 Subject: [PATCH 01/38] fix(desktop): make reconnect repair lossless (#6415) ## Summary - repair exact-channel reconnect gaps from a source-coupled 1,865-second floor (`900` seconds of relay future tolerance + `960` seconds of DB created-at floor + `5` seconds of fence margin) - page the existing authenticated `POST /query` bridge losslessly with `(until, before_id)`, including dense timestamp boundaries and an unbounded first page that does not trust the renderer clock - keep the restored live REQ unchanged while deduplicating live/repair overlap until both live EOSE and repair completion for the active connection generation ## Scope and behavior The wider repair applies only to live subscriptions containing the exact channel-event kind set and one `#h`. Profile, read-state, global, channel-less, and other subscription behavior is unchanged. Failed or stale repairs retain their pinned lower bound for the next reconnect. This intersects the replay path described in #3215, but does not claim to fix that issue. Desktop already has a bounded notification-side event-ID guard; this PR adds transport dispatch dedupe for the restored-live/repair overlap. Notification behavior should remain a focused regression check while this wider repair window rolls out. ## Testing - `cd desktop && pnpm typecheck` - `cd desktop && pnpm biome check src/shared/api/channelReconnectRepair.ts src/shared/api/relayClientSession.ts src/shared/api/relayClientShared.ts src/shared/api/relayClosedRecovery.ts src/shared/api/relayReconnectReplay.ts src/shared/api/relayReconnectReplay.test.mjs src/testing/e2eBridge.ts` - `cd desktop && node --test src/shared/api/relayReconnectReplay.test.mjs` (26 passed) - `cargo test --manifest-path desktop/src-tauri/Cargo.toml channel_reconnect_repair` (2 passed) - pre-push hooks passed at `743283439eb6be7969e19951cbb6720c5e493a04` --------- Signed-off-by: Wes Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- .../src/commands/channel_reconnect_repair.rs | 119 ++++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 1 + .../src/shared/api/channelReconnectRepair.ts | 27 + desktop/src/shared/api/relayClientSession.ts | 27 +- desktop/src/shared/api/relayClientShared.ts | 13 + desktop/src/shared/api/relayClosedRecovery.ts | 52 ++ .../shared/api/relayReconnectReplay.test.mjs | 593 ++++++++++++++++-- .../src/shared/api/relayReconnectReplay.ts | 175 ++++-- desktop/src/testing/e2eBridge.ts | 46 ++ 10 files changed, 951 insertions(+), 104 deletions(-) create mode 100644 desktop/src-tauri/src/commands/channel_reconnect_repair.rs create mode 100644 desktop/src/shared/api/channelReconnectRepair.ts diff --git a/desktop/src-tauri/src/commands/channel_reconnect_repair.rs b/desktop/src-tauri/src/commands/channel_reconnect_repair.rs new file mode 100644 index 0000000000..f47258902b --- /dev/null +++ b/desktop/src-tauri/src/commands/channel_reconnect_repair.rs @@ -0,0 +1,119 @@ +use tauri::State; + +use crate::{app_state::AppState, relay::query_relay}; + +const MAX_REPAIR_PAGE_LIMIT: u32 = 500; +const CHANNEL_REPAIR_KINDS: [u32; 15] = [ + 5, 7, 9, 9005, 40001, 40002, 40003, 40008, 40099, 45001, 45003, 48100, 48101, 48102, 48103, +]; + +fn build_channel_reconnect_repair_filter( + channel_id: &str, + since: u64, + limit: u32, + until: Option, + before_id: Option<&str>, +) -> Result { + uuid::Uuid::parse_str(channel_id).map_err(|_| "invalid channel id".to_string())?; + if limit == 0 || limit > MAX_REPAIR_PAGE_LIMIT { + return Err(format!( + "limit must be between 1 and {MAX_REPAIR_PAGE_LIMIT}" + )); + } + if before_id.is_some() && until.is_none() { + return Err("before_id requires until".to_string()); + } + if let Some(event_id) = before_id { + if event_id.len() != 64 || !event_id.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("before_id must be a 64-character hex event id".to_string()); + } + } + + let mut filter = serde_json::Map::new(); + filter.insert("#h".to_string(), serde_json::json!([channel_id])); + filter.insert("kinds".to_string(), serde_json::json!(CHANNEL_REPAIR_KINDS)); + filter.insert("since".to_string(), serde_json::json!(since)); + filter.insert("limit".to_string(), serde_json::json!(limit)); + if let Some(value) = until { + filter.insert("until".to_string(), serde_json::json!(value)); + } + if let Some(value) = before_id { + filter.insert("before_id".to_string(), serde_json::json!(value)); + } + Ok(serde_json::Value::Object(filter)) +} + +/// Fetch one lossless keyset page for reconnect repair using a fixed channel-event filter. +#[tauri::command] +pub async fn get_channel_reconnect_repair( + channel_id: String, + since: u64, + limit: u32, + until: Option, + before_id: Option, + state: State<'_, AppState>, +) -> Result, String> { + let filter = build_channel_reconnect_repair_filter( + &channel_id, + since, + limit, + until, + before_id.as_deref(), + )?; + Ok(query_relay(&state, &[filter]) + .await? + .iter() + .filter_map(|event| serde_json::to_value(event).ok()) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repair_filter_is_fixed_and_keyset_scoped() { + let id = "ab".repeat(32); + let filter = build_channel_reconnect_repair_filter( + "270f6caf-0feb-4055-93f3-cdbeb567ff28", + 100, + 500, + Some(200), + Some(&id), + ) + .expect("valid filter"); + assert_eq!( + filter["#h"], + serde_json::json!(["270f6caf-0feb-4055-93f3-cdbeb567ff28"]) + ); + assert_eq!(filter["kinds"], serde_json::json!(CHANNEL_REPAIR_KINDS)); + assert_eq!(filter["since"], 100); + assert_eq!(filter["limit"], 500); + assert_eq!(filter["until"], 200); + assert_eq!(filter["before_id"], id); + assert!(filter.get("top_level").is_none()); + assert!(filter.get("include_summaries").is_none()); + assert!(filter.get("include_aux").is_none()); + } + + #[test] + fn repair_filter_rejects_renderer_escape_hatches() { + assert!(build_channel_reconnect_repair_filter("not-a-channel", 0, 1, None, None).is_err()); + assert!(build_channel_reconnect_repair_filter( + "270f6caf-0feb-4055-93f3-cdbeb567ff28", + 0, + 0, + None, + None + ) + .is_err()); + assert!(build_channel_reconnect_repair_filter( + "270f6caf-0feb-4055-93f3-cdbeb567ff28", + 0, + 1, + None, + Some("bad") + ) + .is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 85e9df0b10..7cb2d8e3b8 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -12,6 +12,7 @@ mod agent_settings; mod agent_update_rollback; mod agents; mod canvas; +mod channel_reconnect_repair; mod channel_templates; mod channel_window; mod channels; @@ -81,6 +82,7 @@ pub use agent_providers::*; pub use agent_settings::*; pub use agents::*; pub use canvas::*; +pub use channel_reconnect_repair::*; pub use channel_templates::*; pub use channel_window::*; pub use channels::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f8601b66d0..c120ac1267 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -646,6 +646,7 @@ pub fn run() { get_forum_posts, get_forum_thread, get_thread_replies, + get_channel_reconnect_repair, get_channel_window, get_channel_messages_before, edit_message, diff --git a/desktop/src/shared/api/channelReconnectRepair.ts b/desktop/src/shared/api/channelReconnectRepair.ts new file mode 100644 index 0000000000..16ee65642f --- /dev/null +++ b/desktop/src/shared/api/channelReconnectRepair.ts @@ -0,0 +1,27 @@ +import { invokeTauri } from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; + +export type ChannelReconnectRepairRequest = { + channelId: string; + since: number; + limit: number; + until?: number; + beforeId?: string; +}; + +/** Fetch one fixed-kind, channel-scoped reconnect repair page. */ +export async function getChannelReconnectRepairEvents({ + channelId, + since, + limit, + until, + beforeId, +}: ChannelReconnectRepairRequest): Promise { + return invokeTauri("get_channel_reconnect_repair", { + channelId, + since, + limit, + until: until ?? null, + beforeId: beforeId ?? null, + }); +} diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index a18ec1448b..988013bfbc 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -20,6 +20,7 @@ import { type PendingEvent, type RelaySubscription, type RelaySubscriptionFilter, + type SubscriptionEventBufferItem, } from "@/shared/api/relayClientShared"; import { buildChannelAuxDeletionFilter, @@ -30,10 +31,12 @@ import { } from "@/shared/api/relayChannelFilters"; import { clearClosedRetry, + flushEvents, handleRelayClosed, handleSubscriptionEose, prepareSubscriptionEvent, } from "@/shared/api/relayClosedRecovery"; +import { getChannelReconnectRepairEvents } from "@/shared/api/channelReconnectRepair"; import { replayLiveSubscriptions } from "@/shared/api/relayReconnectReplay"; import { activateRateLimit, @@ -87,7 +90,7 @@ export class RelayClient { private authRequest: RelayAuthRequest | null = null; private subscriptions = new Map(); private pendingEvents = new Map(); - private eventBuffer: Array<{ subId: string; event: RelayEvent }> = []; + private eventBuffer: SubscriptionEventBufferItem[] = []; private flushTimeout: number | null = null; private reconnectListeners = new Set<() => void>(); private hasConnectedOnce = false; @@ -789,7 +792,7 @@ export class RelayClient { return; } if (type === "EVENT" && typeof rest[0] === "string" && rest[1]) { - this.handleEvent(rest[0], rest[1] as RelayEvent); + this.handleEvent(rest[0], rest[1] as RelayEvent, generation); return; } @@ -807,7 +810,7 @@ export class RelayClient { } if (type === "EOSE" && typeof rest[0] === "string") { - this.handleEose(rest[0]); + this.handleEose(rest[0], generation); return; } @@ -852,7 +855,7 @@ export class RelayClient { await this.sendRaw(["AUTH", event]); } - private handleEvent(subId: string, event: RelayEvent) { + private handleEvent(subId: string, event: RelayEvent, generation: number) { const subscription = this.subscriptions.get(subId); if (!subscription) { return; @@ -864,7 +867,7 @@ export class RelayClient { } if (!prepareSubscriptionEvent(subscription, event)) return; - this.eventBuffer.push({ subId, event }); + this.eventBuffer.push({ subId, event, generation }); this.flushTimeout ??= window.setTimeout( () => this.flushEventBuffer(), EVENT_BATCH_MS, @@ -876,21 +879,16 @@ export class RelayClient { const buffer = this.eventBuffer; this.eventBuffer = []; - // Re-lookup: subscriptions removed during batch window are intentionally skipped. - for (const { subId, event } of buffer) { - const subscription = this.subscriptions.get(subId); - if (subscription?.mode === "live") { - subscription.onEvent(event); - } - } + flushEvents(buffer, this.subscriptions, this.connectionGeneration); } - private handleEose(subId: string) { + private handleEose(subId: string, generation: number) { this.flushEventBuffer(); // Deliver preceding EVENT frames before EOSE. handleSubscriptionEose({ subscriptions: this.subscriptions, subId, closeSubscription: (id) => this.closeSubscription(id), + generation, }); } @@ -938,7 +936,8 @@ export class RelayClient { await replayLiveSubscriptions({ subscriptions: this.subscriptions, sendRaw: (payload) => this.sendRaw(payload), - requestHistory: (filter) => this.requestHistory(filter), + requestRepair: getChannelReconnectRepairEvents, + generation, visibleChannelId: this.visibleChannelId, isActive: () => this.connectionGeneration === generation, }); diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts index 49b3fa9d85..5c602cc6de 100644 --- a/desktop/src/shared/api/relayClientShared.ts +++ b/desktop/src/shared/api/relayClientShared.ts @@ -73,6 +73,13 @@ type LiveSubscription = { * `min(pendingReplaySince, cursor window)`. */ pendingReplaySince?: number; + /** Dispatch-level duplicate suppression shared by restored-live and repair. */ + reconnectReplay?: { + generation: number; + seenEventIds: Set; + liveEose: boolean; + repairDone: boolean; + }; closedRetryAttempt?: number; closedRetryTimeout?: number; }; @@ -89,6 +96,12 @@ export type RelaySubscription = | FirstEventSubscription | LiveSubscription; +export type SubscriptionEventBufferItem = { + subId: string; + event: RelayEvent; + generation: number; +}; + export function sortEvents(events: RelayEvent[]) { return [...events].sort((left, right) => { if (left.created_at !== right.created_at) { diff --git a/desktop/src/shared/api/relayClosedRecovery.ts b/desktop/src/shared/api/relayClosedRecovery.ts index e1ff86e08d..a8532d9fde 100644 --- a/desktop/src/shared/api/relayClosedRecovery.ts +++ b/desktop/src/shared/api/relayClosedRecovery.ts @@ -8,6 +8,7 @@ import { sortEvents, type RelaySubscription, type RelaySubscriptionFilter, + type SubscriptionEventBufferItem, } from "@/shared/api/relayClientShared"; import type { RelayEvent } from "@/shared/api/types"; @@ -145,18 +146,69 @@ export function prepareSubscriptionEvent( return true; } +export function shouldDispatchSubscriptionEvent( + subscription: Extract, + event: RelayEvent, +) { + const replay = subscription.reconnectReplay; + if (replay?.seenEventIds.has(event.id)) return false; + replay?.seenEventIds.add(event.id); + return true; +} + +export function flushEvents( + buffer: SubscriptionEventBufferItem[], + subscriptions: Map, + generation: number, +) { + for (const item of buffer) { + const subscription = subscriptions.get(item.subId); + if ( + subscription?.mode === "live" && + item.generation === generation && + shouldDispatchSubscriptionEvent(subscription, item.event) + ) { + subscription.onEvent(item.event); + } + } +} + +export function markReconnectLiveEose( + subscription: Extract, + generation: number, +) { + const replay = subscription.reconnectReplay; + if (!replay || replay.generation !== generation) return; + replay.liveEose = true; + if (replay.repairDone) subscription.reconnectReplay = undefined; +} + +export function markReconnectRepairDone( + subscription: Extract, + generation: number, +) { + const replay = subscription.reconnectReplay; + if (!replay || replay.generation !== generation) return; + replay.repairDone = true; + if (replay.liveEose) subscription.reconnectReplay = undefined; +} + export function handleSubscriptionEose({ subscriptions, subId, closeSubscription, + generation, }: { subscriptions: Map; subId: string; closeSubscription: (subId: string) => Promise; + generation?: number; }) { const subscription = subscriptions.get(subId); if (!subscription) return; if (subscription.mode === "live") { + if (generation !== undefined) + markReconnectLiveEose(subscription, generation); subscription.resolveReady?.("eose"); subscription.resolveReady = undefined; subscription.closedRetryAttempt = 0; diff --git a/desktop/src/shared/api/relayReconnectReplay.test.mjs b/desktop/src/shared/api/relayReconnectReplay.test.mjs index c175272885..8ecffdf04d 100644 --- a/desktop/src/shared/api/relayReconnectReplay.test.mjs +++ b/desktop/src/shared/api/relayReconnectReplay.test.mjs @@ -1,15 +1,27 @@ import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import test from "node:test"; import { buildReconnectReplayFilter, + DB_CREATED_AT_FLOOR_SECS, + FENCE_CLOCK_MARGIN_SECS, + RELAY_INGEST_FUTURE_TOLERANCE_SECS, + RECONNECT_REPLAY_CHANNEL_LOOKBACK_SECS, + replayReconnectHistoryPages, PAGE_REPLAY_MAX_ATTEMPTS, replayLiveSubscriptions, REPLAY_BATCH_SIZE, shouldPageReconnectReplay, } from "./relayReconnectReplay.ts"; import { buildChannelFilter } from "./relayChannelFilters.ts"; -import { prepareSubscriptionEvent } from "./relayClosedRecovery.ts"; +import { + flushEvents, + markReconnectLiveEose, + markReconnectRepairDone, + prepareSubscriptionEvent, + shouldDispatchSubscriptionEvent, +} from "./relayClosedRecovery.ts"; // ── Fake-timer + Date.now setup for gate tests ──────────────────────────────── @@ -77,13 +89,67 @@ function event(id, createdAt) { function eventRange(prefix, start, count) { return Array.from({ length: count }, (_, index) => event(`${prefix}-${index}`, start + index), - ); + ).reverse(); } function replayFilter(filter, since, until) { return buildReconnectReplayFilter(filter, since, until); } +function numericList(source, pattern) { + const match = source.match(pattern); + assert.ok(match, `expected source list to match ${pattern}`); + return [...match[1].matchAll(/\b\d[\d_]*\b/g)].map((value) => + Number(value[0].replaceAll("_", "")), + ); +} + +test("channel replay lookback stays coupled to relay and DB source constants", async () => { + const [ingest, fence] = await Promise.all([ + readFile("../crates/buzz-relay/src/handlers/ingest.rs", "utf8"), + readFile("../crates/buzz-db/src/replica_fence.rs", "utf8"), + ]); + assert.match( + ingest, + new RegExp( + `MAX_TIMESTAMP_DRIFT_SECS: i64 = ${RELAY_INGEST_FUTURE_TOLERANCE_SECS}`, + ), + ); + assert.match( + fence, + new RegExp(`CREATED_AT_FLOOR_SECS: i64 = ${DB_CREATED_AT_FLOOR_SECS}`), + ); + assert.match( + fence, + new RegExp(`FENCE_CLOCK_MARGIN_SECS: i64 = ${FENCE_CLOCK_MARGIN_SECS}`), + ); + assert.equal(RECONNECT_REPLAY_CHANNEL_LOOKBACK_SECS, 1_865); +}); + +test("native and E2E repair kinds stay coupled to the live channel filter", async () => { + const [rustCommand, e2eBridge] = await Promise.all([ + readFile("src-tauri/src/commands/channel_reconnect_repair.rs", "utf8"), + readFile("src/testing/e2eBridge.ts", "utf8"), + ]); + const expectedKinds = [...buildChannelFilter("channel-1", 50).kinds].sort( + (left, right) => left - right, + ); + assert.deepEqual( + numericList( + rustCommand, + /const CHANNEL_REPAIR_KINDS: \[u32; \d+\] = \[([\s\S]*?)\];/, + ).sort((left, right) => left - right), + expectedKinds, + ); + assert.deepEqual( + numericList( + e2eBridge, + /function handleGetChannelReconnectRepair[\s\S]*?const kinds = new Set\(\[([\s\S]*?)\]\);/, + ).sort((left, right) => left - right), + expectedKinds, + ); +}); + // ── buildReconnectReplayFilter ──────────────────────────────────────────────── test("reconnect replay preserves small steady-state limits when adding since", () => { @@ -208,7 +274,7 @@ test("replay sends all subs in one batch when count equals REPLAY_BATCH_SIZE", a sendRaw: async (payload) => { sentIds.push(payload[1]); }, - requestHistory: async () => [], + requestRepair: async () => [], setTimeoutFn: (fn, _ms) => { delayCount++; fn(); @@ -244,7 +310,7 @@ test("replay splits subscriptions into batches of REPLAY_BATCH_SIZE", async () = sendRaw: async (payload) => { sentIds.push(payload[1]); }, - requestHistory: async () => [], + requestRepair: async () => [], setTimeoutFn: (fn, _ms) => { delayCount++; batchBreakpoints.push(sentIds.length); @@ -259,6 +325,133 @@ test("replay splits subscriptions into batches of REPLAY_BATCH_SIZE", async () = assert.equal(batchBreakpoints[0], REPLAY_BATCH_SIZE); }); +test("inter-batch disposal cannot resurrect a closed subscription", async () => { + resetGate(); + const frames = []; + const secondSubscription = { + mode: "live", + filter: { kinds: [9], "#h": ["ch-2"], limit: 50 }, + onEvent: () => {}, + lastSeenCreatedAt: undefined, + }; + const subscriptions = new Map([ + [ + "sub-1", + { + mode: "live", + filter: { kinds: [9], "#h": ["ch-1"], limit: 50 }, + onEvent: () => {}, + lastSeenCreatedAt: undefined, + }, + ], + ["sub-2", secondSubscription], + ]); + + await replayLiveSubscriptions({ + subscriptions, + replayBatchSize: 1, + sendRaw: async (payload) => { + frames.push(`${payload[0]}:${payload[1]}`); + }, + requestRepair: async () => [], + setTimeoutFn: (fn, _ms) => { + assert.equal(subscriptions.delete("sub-2"), true); + frames.push("CLOSE:sub-2"); + fn(); + return 0; + }, + }); + + assert.deepEqual(frames, ["REQ:sub-1", "CLOSE:sub-2"]); +}); + +test("in-flight disposal is closed again after the stale REQ settles", async () => { + resetGate(); + const frames = []; + let markReqStarted; + const reqStarted = new Promise((resolve) => { + markReqStarted = resolve; + }); + let releaseReq; + const heldReq = new Promise((resolve) => { + releaseReq = resolve; + }); + const subscription = { + mode: "live", + filter: { kinds: [9], "#h": ["ch-1"], limit: 50 }, + onEvent: () => {}, + lastSeenCreatedAt: undefined, + }; + const subscriptions = new Map([["sub-1", subscription]]); + + const replayPromise = replayLiveSubscriptions({ + subscriptions, + sendRaw: async (payload) => { + frames.push(`${payload[0]}:${payload[1]}`); + if (payload[0] === "REQ") { + markReqStarted(); + await heldReq; + } + }, + requestRepair: async () => [], + }); + + await reqStarted; + assert.equal(subscriptions.delete("sub-1"), true); + // The ordinary disposer emits CLOSE while the websocket REQ invoke is held. + frames.push("CLOSE:sub-1"); + releaseReq(); + await replayPromise; + + assert.deepEqual(frames, ["REQ:sub-1", "CLOSE:sub-1", "CLOSE:sub-1"]); +}); + +test("inter-batch replacement cannot be overwritten by a stale REQ", async () => { + resetGate(); + const sentFilters = []; + const subscriptions = new Map([ + [ + "sub-1", + { + mode: "live", + filter: { kinds: [9], "#h": ["ch-1"], limit: 50 }, + onEvent: () => {}, + lastSeenCreatedAt: undefined, + }, + ], + [ + "sub-2", + { + mode: "live", + filter: { kinds: [9], "#h": ["ch-old"], limit: 50 }, + onEvent: () => {}, + lastSeenCreatedAt: undefined, + }, + ], + ]); + + await replayLiveSubscriptions({ + subscriptions, + replayBatchSize: 1, + sendRaw: async (payload) => { + sentFilters.push(payload[2]); + }, + requestRepair: async () => [], + setTimeoutFn: (fn, _ms) => { + subscriptions.set("sub-2", { + mode: "live", + filter: { kinds: [9], "#h": ["ch-new"], limit: 50 }, + onEvent: () => {}, + lastSeenCreatedAt: undefined, + }); + fn(); + return 0; + }, + }); + + assert.deepEqual(sentFilters, [{ kinds: [9], "#h": ["ch-1"], limit: 50 }]); +}); + // ── Visible-channel priority ────────────────────────────────────────────────── test("visible channel subscription is sent first", async () => { @@ -300,7 +493,7 @@ test("visible channel subscription is sent first", async () => { sendRaw: async (payload) => { sentOrder.push(payload[1]); }, - requestHistory: async () => [], + requestRepair: async () => [], visibleChannelId: "ch-visible", }); @@ -331,7 +524,7 @@ test("replay waits for rate-limit gate before sending REQs", async () => { sendRaw: async (payload) => { sentIds.push(payload[1]); }, - requestHistory: async () => [], + requestRepair: async () => [], setTimeoutFn: (fn, _ms) => { fn(); return 0; @@ -370,7 +563,7 @@ test("stale replay sends no REQs when generation advances while gate was active" sendRaw: async (payload) => { sentIds.push(payload[1]); }, - requestHistory: async () => [], + requestRepair: async () => [], isActive: () => generationActive, }); @@ -412,12 +605,11 @@ test("channel reconnect replay pages the missed window until a short page", asyn await replayLiveSubscriptions({ subscriptions, - now: 2000, sendRaw: async (payload) => { sentPayloads.push(payload); }, - requestHistory: async (filter) => { - historyFilters.push(filter); + requestRepair: async (request) => { + historyFilters.push(request); return pages.shift() ?? []; }, }); @@ -435,30 +627,71 @@ test("channel reconnect replay pages the missed window until a short page", asyn ]); assert.deepEqual(historyFilters, [ { - kinds: filter.kinds, - "#h": ["channel-1"], + channelId: "channel-1", limit: 500, - since: 995, - until: 2000, + since: 0, + until: undefined, + beforeId: undefined, }, { - kinds: filter.kinds, - "#h": ["channel-1"], + channelId: "channel-1", limit: 500, - since: 995, + since: 0, until: 1501, + beforeId: "newest-0", }, { - kinds: filter.kinds, - "#h": ["channel-1"], + channelId: "channel-1", limit: 500, - since: 995, + since: 0, until: 1002, + beforeId: "middle-0", }, ]); assert.equal(delivered.length, 1008); }); +test("native repair preserves an explicit subscription since boundary", async () => { + resetGate(); + const repairRequests = []; + const filter = { + ...buildChannelFilter("channel-1", 1000), + since: 10_000, + }; + const subscriptions = new Map([ + [ + "live-1", + { + mode: "live", + filter, + onEvent: () => {}, + // The repair lookback would otherwise cross the from-now boundary. + lastSeenCreatedAt: 11_000, + }, + ], + ]); + + await replayLiveSubscriptions({ + subscriptions, + sendRaw: async () => {}, + requestRepair: async (request) => { + repairRequests.push(request); + return []; + }, + }); + + assert.equal(11_000 - RECONNECT_REPLAY_CHANNEL_LOOKBACK_SECS, 9_135); + assert.deepEqual(repairRequests, [ + { + channelId: "channel-1", + limit: 500, + since: 10_000, + until: undefined, + beforeId: undefined, + }, + ]); +}); + test("reconnect replay starts live REQs in parallel and preserves per-sub page order", async () => { resetGate(); const sentPayloads = []; @@ -500,7 +733,6 @@ test("reconnect replay starts live REQs in parallel and preserves per-sub page o const replayPromise = replayLiveSubscriptions({ subscriptions, - now: 2000, pageReplayConcurrency: 2, sendRaw: (payload) => { sentPayloads.push(payload); @@ -508,9 +740,9 @@ test("reconnect replay starts live REQs in parallel and preserves per-sub page o sendResolvers.push(resolve); }); }, - requestHistory: async (filter) => { - const channelId = filter["#h"]?.[0]; - historyFiltersByChannel[channelId].push(filter.until); + requestRepair: async (request) => { + const channelId = request.channelId; + historyFiltersByChannel[channelId].push(request.until); return pagesByChannel[channelId].shift() ?? []; }, }); @@ -533,8 +765,8 @@ test("reconnect replay starts live REQs in parallel and preserves per-sub page o await replayPromise; assert.deepEqual(historyFiltersByChannel, { - "channel-1": [2000, 1501], - "channel-2": [2000, 1701], + "channel-1": [undefined, 1501], + "channel-2": [undefined, 1701], }); }); @@ -574,7 +806,7 @@ test("batch-1 arms gate mid-replay: batch-2 is withheld until gate expires", asy activateRateLimit(5); } }, - requestHistory: async () => [], + requestRepair: async () => [], setTimeoutFn: (fn, _ms) => { fn(); return 0; @@ -602,6 +834,132 @@ test("batch-1 arms gate mid-replay: batch-2 is withheld until gate expires", asy ); }); +test("per-batch gate re-arm skips a subscription disposed while waiting", async () => { + resetGate(0); + const sentIds = []; + let gateArmed; + const gateArmedPromise = new Promise((resolve) => { + gateArmed = resolve; + }); + const subscriptions = new Map([ + [ + "sub-1", + { + mode: "live", + filter: { kinds: [9], "#h": ["ch-1"], limit: 50 }, + onEvent: () => {}, + lastSeenCreatedAt: undefined, + }, + ], + [ + "sub-2", + { + mode: "live", + filter: { kinds: [9], "#h": ["ch-2"], limit: 50 }, + onEvent: () => {}, + lastSeenCreatedAt: undefined, + }, + ], + ]); + + const replayPromise = replayLiveSubscriptions({ + subscriptions, + replayBatchSize: 1, + sendRaw: async (payload) => { + sentIds.push(payload[1]); + if (payload[1] === "sub-1") { + activateRateLimit(5); + gateArmed(); + } + }, + requestRepair: async () => [], + setTimeoutFn: (fn, _ms) => { + fn(); + return 0; + }, + }); + + await gateArmedPromise; + assert.equal(subscriptions.delete("sub-2"), true); + tickTo(5_001); + await replayPromise; + + assert.deepEqual(sentIds, ["sub-1"]); +}); + +test("disposed subscription receives no repair events after an in-flight page", async () => { + resetGate(); + const delivered = []; + const subscription = { + mode: "live", + filter: buildChannelFilter("channel-1", 50), + onEvent: (value) => delivered.push(value.id), + lastSeenCreatedAt: 1000, + }; + const subscriptions = new Map([["live-1", subscription]]); + + await replayLiveSubscriptions({ + subscriptions, + sendRaw: async () => {}, + requestRepair: async () => { + assert.equal(subscriptions.delete("live-1"), true); + return [event("disposed", 1001)]; + }, + }); + + assert.deepEqual(delivered, []); +}); + +test("stale repair cannot clear the successor generation dedupe state", async () => { + resetGate(); + const duplicate = event("successor-seen", 1001); + const subscription = { + mode: "live", + filter: buildChannelFilter("channel-1", 50), + onEvent: () => {}, + lastSeenCreatedAt: 1000, + }; + const subscriptions = new Map([["live-1", subscription]]); + let generationActive = true; + let startRepair; + const repairStarted = new Promise((resolve) => { + startRepair = resolve; + }); + let finishRepair; + const repairPage = new Promise((resolve) => { + finishRepair = resolve; + }); + + const replayPromise = replayLiveSubscriptions({ + subscriptions, + generation: 10, + isActive: () => generationActive, + sendRaw: async () => {}, + requestRepair: async () => { + startRepair(); + return repairPage; + }, + }); + + await repairStarted; + generationActive = false; + subscription.reconnectReplay = { + generation: 11, + seenEventIds: new Set([duplicate.id]), + liveEose: false, + repairDone: false, + }; + finishRepair([duplicate]); + await replayPromise; + + assert.equal(subscription.reconnectReplay.generation, 11); + assert.deepEqual( + [...subscription.reconnectReplay.seenEventIds], + [duplicate.id], + ); + assert.equal(shouldDispatchSubscriptionEvent(subscription, duplicate), false); +}); + // ── Backfill failure containment ───────────────────────────────────────────── test("history backfill rejection never escapes replayLiveSubscriptions", async () => { @@ -623,9 +981,8 @@ test("history backfill rejection never escapes replayLiveSubscriptions", async ( // Must resolve — a rejection here is the socket-killing flap regression. await replayLiveSubscriptions({ subscriptions, - now: 2000, sendRaw: async () => {}, - requestHistory: async () => { + requestRepair: async () => { historyCalls++; throw new Error("rate-limited: quota exceeded; retry in 4s"); }, @@ -661,9 +1018,8 @@ test("backfill retry waits out the armed gate, then succeeds", async () => { }); const replayPromise = replayLiveSubscriptions({ subscriptions, - now: 2000, sendRaw: async () => {}, - requestHistory: async () => { + requestRepair: async () => { attemptAtMs.push(fakeNow); if (attemptAtMs.length === 1) { // Mirror relayClosedRecovery: the CLOSED handler arms the gate @@ -708,9 +1064,8 @@ test("backfill retry aborts when the subscription was replaced", async () => { let historyCalls = 0; await replayLiveSubscriptions({ subscriptions, - now: 2000, sendRaw: async () => {}, - requestHistory: async () => { + requestRepair: async () => { historyCalls++; // Simulate the subscription being torn down while the REQ is in flight. subscriptions.delete("live-1"); @@ -743,15 +1098,14 @@ test("exhausted backfill pins the floor: next replay still requests the original // Reconnect 1: every backfill attempt is rate-limited. await replayLiveSubscriptions({ subscriptions, - now: 2000, sendRaw: async () => {}, - requestHistory: async () => { + requestRepair: async () => { throw new Error("rate-limited: quota exceeded; retry in 4s"); }, }); assert.equal( subscription.pendingReplaySince, - 995, + 0, "exhausted backfill must pin the unresolved window's lower bound", ); @@ -763,10 +1117,9 @@ test("exhausted backfill pins the floor: next replay still requests the original const historyFilters = []; await replayLiveSubscriptions({ subscriptions, - now: 2200, sendRaw: async () => {}, - requestHistory: async (filter) => { - historyFilters.push(filter); + requestRepair: async (request) => { + historyFilters.push(request); return []; }, }); @@ -774,7 +1127,7 @@ test("exhausted backfill pins the floor: next replay still requests the original assert.equal(historyFilters.length, 1); assert.equal( historyFilters[0].since, - 995, + 0, "replay must start from the pinned floor, not the advanced cursor", ); assert.equal( @@ -787,16 +1140,15 @@ test("exhausted backfill pins the floor: next replay still requests the original const laterFilters = []; await replayLiveSubscriptions({ subscriptions, - now: 2300, sendRaw: async () => {}, - requestHistory: async (filter) => { - laterFilters.push(filter); + requestRepair: async (request) => { + laterFilters.push(request); return []; }, }); assert.equal( laterFilters[0].since, - 2095, + 235, "after recovery the cursor governs again", ); }); @@ -822,10 +1174,9 @@ test("in-flight stale abort keeps the pinned floor for the superseding connectio let historyCalls = 0; await replayLiveSubscriptions({ subscriptions, - now: 2000, sendRaw: async () => {}, isActive: () => generationActive, - requestHistory: async () => { + requestRepair: async () => { historyCalls++; // Connection A is superseded while the REQ is in flight: the generation // advances, but the subscription keeps its key AND object identity — @@ -845,11 +1196,159 @@ test("in-flight stale abort keeps the pinned floor for the superseding connectio ); assert.equal( subscription.pendingReplaySince, - 995, + 0, "a stale-generation abort must not clear the floor the new connection needs", ); }); +test("future-dated cursor repairs backdated edits and deletions", async () => { + resetGate(); + const delivered = []; + const recovered = [ + { ...event("5".repeat(64), 10_010), kind: 5 }, + { ...event("6".repeat(64), 10_011), kind: 9005 }, + { ...event("7".repeat(64), 10_012), kind: 40003 }, + ]; + const subscription = { + mode: "live", + filter: buildChannelFilter("channel-1", 50), + onEvent: (value) => delivered.push(value), + // Relay accepted this event 900s in the future before disconnect. + lastSeenCreatedAt: 10_900, + }; + const requests = []; + await replayLiveSubscriptions({ + subscriptions: new Map([["live-1", subscription]]), + generation: 11, + sendRaw: async () => {}, + requestRepair: async (request) => { + requests.push(request); + return recovered; + }, + }); + assert.equal(requests[0].since, 9_035); + assert.equal(requests[0].until, undefined); + assert.deepEqual( + delivered.map((value) => value.kind), + [5, 9005, 40003], + ); +}); + +test("dense-second repair advances by event id without gaps", async () => { + resetGate(); + const delivered = []; + const requests = []; + const subscription = { + mode: "live", + filter: buildChannelFilter("channel-1", 50), + onEvent: (value) => delivered.push(value.id), + lastSeenCreatedAt: 2000, + }; + const dense = Array.from({ length: 1_205 }, (_, index) => + event(index.toString().padStart(64, "0"), 2000), + ); + await replayLiveSubscriptions({ + subscriptions: new Map([["live-1", subscription]]), + generation: 7, + sendRaw: async () => {}, + requestRepair: async (request) => { + requests.push(request); + const start = request.beforeId + ? dense.findIndex((value) => value.id === request.beforeId) + 1 + : 0; + return dense.slice(start, start + request.limit); + }, + }); + assert.equal(delivered.length, dense.length); + assert.equal(new Set(delivered).size, dense.length); + assert.deepEqual( + requests.map((request) => request.beforeId), + [undefined, dense[499].id, dense[999].id], + ); +}); + +test("replay dedupe clears only after both completions and never from stale generation", () => { + for (const repairFirst of [true, false]) { + const subscription = { + mode: "live", + filter: buildChannelFilter("channel-1", 50), + onEvent: () => {}, + reconnectReplay: { + generation: 12, + seenEventIds: new Set(["seen"]), + liveEose: false, + repairDone: false, + }, + }; + markReconnectRepairDone(subscription, 11); + markReconnectLiveEose(subscription, 11); + assert.equal(subscription.reconnectReplay.generation, 12); + if (repairFirst) { + markReconnectRepairDone(subscription, 12); + assert.ok(subscription.reconnectReplay); + markReconnectLiveEose(subscription, 12); + } else { + markReconnectLiveEose(subscription, 12); + assert.ok(subscription.reconnectReplay); + markReconnectRepairDone(subscription, 12); + } + assert.equal(subscription.reconnectReplay, undefined); + } +}); + +test("buffer flush drops stale generations and removed subscriptions", () => { + const delivered = []; + const subscription = { + mode: "live", + filter: buildChannelFilter("channel-1", 50), + onEvent: (value) => delivered.push(value.id), + }; + flushEvents( + [ + { subId: "live-1", event: event("stale", 100), generation: 6 }, + { subId: "removed", event: event("removed", 101), generation: 7 }, + { subId: "live-1", event: event("current", 102), generation: 7 }, + ], + new Map([["live-1", subscription]]), + 7, + ); + assert.deepEqual(delivered, ["current"]); +}); + +test("live-before-repair and repair-before-live dispatch once", async () => { + const shared = event("a".repeat(64), 1000); + for (const liveFirst of [true, false]) { + const delivered = []; + const subscription = { + mode: "live", + filter: buildChannelFilter("channel-1", 50), + onEvent: (value) => delivered.push(value.id), + reconnectReplay: { + generation: 4, + seenEventIds: new Set(), + liveEose: false, + repairDone: false, + }, + }; + if (liveFirst) { + assert.equal(shouldDispatchSubscriptionEvent(subscription, shared), true); + subscription.onEvent(shared); + } + await replayReconnectHistoryPages({ + subscription, + channelId: "channel-1", + since: 0, + until: 1000, + isActive: () => true, + requestRepair: async () => [shared], + }); + if (!liveFirst && shouldDispatchSubscriptionEvent(subscription, shared)) { + subscription.onEvent(shared); + } + assert.deepEqual(delivered, [shared.id]); + } +}); + // ── Teardown ────────────────────────────────────────────────────────────────── test("teardown — restore Date.now", () => { diff --git a/desktop/src/shared/api/relayReconnectReplay.ts b/desktop/src/shared/api/relayReconnectReplay.ts index cb962ce846..60a57f1a0b 100644 --- a/desktop/src/shared/api/relayReconnectReplay.ts +++ b/desktop/src/shared/api/relayReconnectReplay.ts @@ -4,12 +4,28 @@ import type { RelaySubscriptionFilter, } from "@/shared/api/relayClientShared"; import type { RelayEvent } from "@/shared/api/types"; +import { + markReconnectRepairDone, + shouldDispatchSubscriptionEvent, +} from "@/shared/api/relayClosedRecovery"; +import type { ChannelReconnectRepairRequest } from "@/shared/api/channelReconnectRepair"; import { isRateLimited, waitForRateLimit, } from "@/shared/api/relayRateLimitGate"; const RECONNECT_REPLAY_SKEW_SECS = 5; +/** + * Legacy cursors are event-time maxima, so cover relay future tolerance (900s) + * plus the channel-row storage fence and margin (960s + 5s). + */ +export const RELAY_INGEST_FUTURE_TOLERANCE_SECS = 900; +export const DB_CREATED_AT_FLOOR_SECS = 960; +export const FENCE_CLOCK_MARGIN_SECS = 5; +export const RECONNECT_REPLAY_CHANNEL_LOOKBACK_SECS = + RELAY_INGEST_FUTURE_TOLERANCE_SECS + + DB_CREATED_AT_FLOOR_SECS + + FENCE_CLOCK_MARGIN_SECS; export const RECONNECT_REPLAY_PAGE_LIMIT = 500; export const RECONNECT_REPLAY_PAGE_CONCURRENCY = 4; @@ -106,41 +122,58 @@ export function shouldPageReconnectReplay(filter: RelaySubscriptionFilter) { */ export async function replayReconnectHistoryPages({ subscription, + channelId, since, until, isActive, - requestHistory, + requestRepair, }: { subscription: Extract; + channelId: string; since: number; - until: number; + until?: number; isActive: () => boolean; - requestHistory: (filter: RelaySubscriptionFilter) => Promise; + requestRepair: ( + request: ChannelReconnectRepairRequest, + ) => Promise; }): Promise { - let pageUntil = until; + let pageUntil: number | undefined = until; + let beforeId: string | undefined; - while (pageUntil >= since) { + while (pageUntil === undefined || pageUntil >= since) { if (!isActive()) return false; - const events = await requestHistory( - buildReconnectReplayFilter( - subscription.filter, - since, - pageUntil, - RECONNECT_REPLAY_PAGE_LIMIT, - ), - ); + const events = await requestRepair({ + channelId, + since, + limit: RECONNECT_REPLAY_PAGE_LIMIT, + until: pageUntil, + beforeId, + }); if (!isActive()) return false; - for (const event of events) subscription.onEvent(event); + for (const event of events) { + if (shouldDispatchSubscriptionEvent(subscription, event)) { + subscription.onEvent(event); + } + } if (events.length < RECONNECT_REPLAY_PAGE_LIMIT) return true; - const oldestCreatedAt = events[0]?.created_at; - if (oldestCreatedAt === undefined || oldestCreatedAt <= since) return true; - - pageUntil = - oldestCreatedAt < pageUntil ? oldestCreatedAt : oldestCreatedAt - 1; + const terminal = events.at(-1); + if (!terminal || terminal.created_at < since) return true; + if (terminal.created_at === pageUntil && terminal.id === beforeId) { + throw new Error("Reconnect repair cursor did not advance"); + } + if (terminal.created_at === since) { + // Continue across an arbitrarily dense boundary second until the relay + // returns a short page; the composite cursor, not timestamp subtraction, + // guarantees progress. + pageUntil = since; + } else { + pageUntil = terminal.created_at; + } + beforeId = terminal.id; } return true; } @@ -148,8 +181,8 @@ export async function replayReconnectHistoryPages({ export async function replayLiveSubscriptions({ subscriptions, sendRaw, - requestHistory, - now = Math.floor(Date.now() / 1_000), + requestRepair, + generation = 0, pageReplayConcurrency = RECONNECT_REPLAY_PAGE_CONCURRENCY, visibleChannelId = null, replayBatchSize = REPLAY_BATCH_SIZE, @@ -160,8 +193,10 @@ export async function replayLiveSubscriptions({ }: { subscriptions: Map; sendRaw: (payload: unknown[]) => Promise; - requestHistory: (filter: RelaySubscriptionFilter) => Promise; - now?: number; + requestRepair: ( + request: ChannelReconnectRepairRequest, + ) => Promise; + generation?: number; pageReplayConcurrency?: number; /** Channel currently visible in the UI — its subscriptions go in the first batch. */ visibleChannelId?: string | null; @@ -194,26 +229,53 @@ export async function replayLiveSubscriptions({ entry[1].mode === "live", ) .map(([subId, subscription]) => { + const channelId = subscription.filter["#h"]?.[0]; + const shouldPageReplay = + channelId !== undefined && + shouldPageReconnectReplay(subscription.filter); const cursorSince = subscription.lastSeenCreatedAt === undefined ? undefined : Math.max( 0, - subscription.lastSeenCreatedAt - RECONNECT_REPLAY_SKEW_SECS, + subscription.lastSeenCreatedAt - + (shouldPageReplay + ? RECONNECT_REPLAY_CHANNEL_LOOKBACK_SECS + : RECONNECT_REPLAY_SKEW_SECS), ); // A pinned floor from a previously failed backfill takes precedence // over the cursor: live events kept advancing `lastSeenCreatedAt` // while the older window stayed unresolved, and starting from the // cursor would skip it permanently. - const replaySince = + const replayFloor = cursorSince === undefined ? subscription.pendingReplaySince : Math.min(cursorSince, subscription.pendingReplaySince ?? Infinity); - const shouldPageReplay = - replaySince !== undefined && - shouldPageReconnectReplay(subscription.filter); + // Native repair bypasses the original WS filter, so preserve its lower + // bound explicitly. Otherwise a from-now subscription can replay older + // rows and surface stale unread or notification activity on reconnect. + const replaySince = + replayFloor === undefined + ? undefined + : Math.max(replayFloor, subscription.filter.since ?? 0); + const willRepair = shouldPageReplay && replaySince !== undefined; + if (willRepair) { + // Install before the restored live REQ: a live frame may beat page one. + subscription.reconnectReplay = { + generation, + seenEventIds: new Set(), + liveEose: false, + repairDone: false, + }; + } - return { subId, subscription, replaySince, shouldPageReplay }; + return { + subId, + subscription, + channelId, + replaySince, + shouldPageReplay: willRepair, + }; }); // Sort the visible channel's subscriptions first so the user sees their @@ -244,14 +306,28 @@ export async function replayLiveSubscriptions({ if (!isActive()) return; const batch = replayRequests.slice(i, i + replayBatchSize); await Promise.all( - batch.map(({ subId, subscription, replaySince, shouldPageReplay }) => - sendRaw([ - "REQ", - subId, - shouldPageReplay - ? subscription.filter - : buildReconnectReplayFilter(subscription.filter, replaySince), - ]), + batch.map( + async ({ subId, subscription, replaySince, shouldPageReplay }) => { + // The subscription map may change while replay is paused behind the + // gate or an inter-batch delay. Never resurrect a disposed entry (or + // overwrite its replacement) with a REQ from this stale snapshot. + if (!isActive() || subscriptions.get(subId) !== subscription) return; + await sendRaw([ + "REQ", + subId, + shouldPageReplay + ? subscription.filter + : buildReconnectReplayFilter(subscription.filter, replaySince), + ]); + // Disposal can race the asynchronous websocket invoke after the + // identity check. If this exact entry was deleted while REQ was in + // flight, CLOSE again after REQ settles so wire order converges to + // closed. A different object under the same ID is a replacement and + // must not be torn down by this stale replay. + if (isActive() && !subscriptions.has(subId)) { + await sendRaw(["CLOSE", subId]); + } + }, ), ); if (i + replayBatchSize < replayRequests.length) { @@ -266,12 +342,16 @@ export async function replayLiveSubscriptions({ ( request, ): request is typeof request & { + channelId: string; replaySince: number; shouldPageReplay: true; - } => request.shouldPageReplay && request.replaySince !== undefined, + } => + request.shouldPageReplay && + request.channelId !== undefined && + request.replaySince !== undefined, ), pageReplayConcurrency, - async ({ subId, subscription, replaySince }) => { + async ({ subId, subscription, channelId, replaySince }) => { // Backfill is best-effort: a failure here (typically a `rate-limited:` // CLOSED on a history REQ) must never escape to the session and tear // down the healthy, authenticated socket carrying the live REQs — that @@ -288,8 +368,11 @@ export async function replayLiveSubscriptions({ try { const completed = await replayReconnectHistoryPages({ subscription, + channelId, since: replaySince, - until: now, + // Do not trust the renderer clock as the upper bound. The relay's + // newest matching row starts the keyset walk. + until: undefined, // Both guards are required. The identity check catches the sub // being torn down/replaced; the outer isActive() catches // connection supersession, which bumps the generation while the @@ -298,20 +381,26 @@ export async function replayLiveSubscriptions({ // floor the superseding connection needs. isActive: () => isActive() && subscriptions.get(subId) === subscription, - requestHistory, + requestRepair, }); // A stale-connection abort is NOT completion: the superseding // connection shares this subscription object and still needs the // pinned floor for its own replay. Only a genuinely completed // window may release it. - if (completed) subscription.pendingReplaySince = undefined; + if (completed) { + subscription.pendingReplaySince = undefined; + markReconnectRepairDone(subscription, generation); + } return; } catch (error) { console.warn( `[reconnect replay] history backfill attempt ${attempt}/${PAGE_REPLAY_MAX_ATTEMPTS} failed for ${subId}:`, error, ); - if (attempt === PAGE_REPLAY_MAX_ATTEMPTS) return; + if (attempt === PAGE_REPLAY_MAX_ATTEMPTS) { + markReconnectRepairDone(subscription, generation); + return; + } // The failed REQ's CLOSED handler arms the rate-limit gate before // rejecting; wait for it (no-op when the failure wasn't back-pressure) // and re-check that this replay's connection is still current. diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 6a246572b4..f04959306d 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -5419,6 +5419,47 @@ function buildMockChannelWindowBounds( * This is the window read-model surface the overhaul introduced; without it the * relay-mode bridge has no handler and the timeline renders empty. */ +async function handleGetChannelReconnectRepair( + args: { + channelId: string; + since: number; + limit: number; + until?: number | null; + beforeId?: string | null; + }, + config: E2eConfig | undefined, +): Promise { + const kinds = new Set([ + 5, 7, 9, 9005, 40001, 40002, 40003, 40008, 40099, 45001, 45003, 48100, + 48101, 48102, 48103, + ]); + const filter: Record = { + "#h": [args.channelId], + kinds: [...kinds], + since: args.since, + limit: args.limit, + }; + if (args.until != null) filter.until = args.until; + if (args.beforeId != null) filter.before_id = args.beforeId; + + if (getIdentity(config)) return relayQuery(config, [filter]); + return getMockMessageStore(args.channelId) + .filter( + (event) => + kinds.has(event.kind) && + event.created_at >= args.since && + (args.until == null || + event.created_at < args.until || + (event.created_at === args.until && + (args.beforeId == null || event.id > args.beforeId))), + ) + .sort( + (left, right) => + right.created_at - left.created_at || left.id.localeCompare(right.id), + ) + .slice(0, args.limit); +} + async function handleGetChannelWindow( args: { channelId: string; @@ -13367,6 +13408,11 @@ export function maybeInstallE2eTauriMocks() { payload as Parameters[0], activeConfig, ); + case "get_channel_reconnect_repair": + return handleGetChannelReconnectRepair( + payload as Parameters[0], + activeConfig, + ); case "get_channel_window": return handleGetChannelWindow( payload as Parameters[0], From b298a175c4b9da1638f84c1f287e42d16a690a61 Mon Sep 17 00:00:00 2001 From: Matt Krueger Date: Thu, 20 Aug 2026 19:28:06 -0400 Subject: [PATCH 02/38] fix(desktop): isolate main timeline stacking context from focus drawer (#6398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Jump to latest" pill (`z-50`) in the main timeline renders on top of the focus thread drawer (`z-41`) because both share the same stacking context. This wraps the timeline contents in an `isolate` div so the pill's z-index can't escape above the drawer. The diff is large but the actual change is two lines — one wrapper `
`. The rest is biome re-indenting the children. The `DropZoneOverlay` also moves inside the wrapper, so its top edge no longer extends behind the header during drag-over. ### Repro 1. Open a channel with enough history to scroll up past the bottom 2. Scroll up so the "Jump to latest" pill appears 3. Open a thread in focus mode — the pill renders on top of the drawer ### Related issue None found. Before: Before After: after-drawer-hides-pill Signed-off-by: Matt Krueger Co-authored-by: Claude Code --- .../src/features/channels/ui/ChannelPane.tsx | 360 +++++++++--------- .../channels/ui/FocusThreadDrawer.tsx | 10 +- 2 files changed, 187 insertions(+), 183 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 1ec6cee95e..82da2e4214 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -567,192 +567,194 @@ export const ChannelPane = React.memo(function ChannelPane({ } > {isHuddleTranscript ? null : header} - : undefined - } - huddleMemberPubkeys={huddleMemberPubkeys} - huddleMemberPubkeysPending={huddleMemberPubkeysPending} - isFetchingOlder={isFetchingOlder} - isFollowingThreadById={isFollowingThreadById} - isMessageUnreadById={isMessageUnreadById} - personaLookup={personaLookup} - profiles={profiles} - ownerProfiles={ownerProfiles} - unfollowThreadById={unfollowThreadById} - emptyDescription={ - activeChannel?.channelType === "forum" - ? "Select a stream or DM to load real message history in this first integration pass." - : "Messages and sub-replies will appear here once the relay has history for this channel." - } - emptyTitle={ - activeChannel - ? activeChannel.channelType === "forum" - ? "Forum channels are next" - : "No messages yet" - : "No channel selected" - } - isLoading={isHuddleTranscript ? false : isTimelineLoading} - entranceMessageId={entranceMessageId} - onEntranceMessageComplete={onEntranceMessageComplete} - mainEntries={mainTimelineEntries} - threadSummaries={threadSummaries} - messages={visibleMessages} - firstUnreadMessageId={firstUnreadMessageId} - unreadCount={unreadCount} - onDelete={onDelete} - onEdit={onEdit} - onMarkUnread={onMarkUnread} - onMarkRead={onMarkRead} - onReply={timelineReplyHandler} - onOpenThread={isHuddleTranscript ? undefined : onOpenThread} - channelName={activeChannel?.name} - channelType={activeChannel?.channelType ?? null} - isSendingVideoReviewComment={isSending} - onSendVideoReviewComment={ - activeChannel?.archivedAt ? undefined : onSendVideoReviewComment - } - onTargetReached={onTargetReached} - onToggleReaction={onToggleReaction} - targetMessageId={targetMessageId} - splitThreadPanelOpen={ - useSplitAuxiliaryPane && - !useFocusThreadDrawer && - Boolean(openThreadHeadId) - } - threadUnreadCounts={threadUnreadCounts} - /> - {isNonMemberView ? ( -
-
- - - Viewing{" "} - - #{activeChannel?.name} - - -
- -
- ) : ( -
- +
+ : undefined + } + huddleMemberPubkeys={huddleMemberPubkeys} + huddleMemberPubkeysPending={huddleMemberPubkeysPending} + isFetchingOlder={isFetchingOlder} + isFollowingThreadById={isFollowingThreadById} + isMessageUnreadById={isMessageUnreadById} + personaLookup={personaLookup} + profiles={profiles} + ownerProfiles={ownerProfiles} + unfollowThreadById={unfollowThreadById} + emptyDescription={ + activeChannel?.channelType === "forum" + ? "Select a stream or DM to load real message history in this first integration pass." + : "Messages and sub-replies will appear here once the relay has history for this channel." + } + emptyTitle={ + activeChannel + ? activeChannel.channelType === "forum" + ? "Forum channels are next" + : "No messages yet" + : "No channel selected" + } + isLoading={isHuddleTranscript ? false : isTimelineLoading} + entranceMessageId={entranceMessageId} + onEntranceMessageComplete={onEntranceMessageComplete} + mainEntries={mainTimelineEntries} + threadSummaries={threadSummaries} + messages={visibleMessages} + firstUnreadMessageId={firstUnreadMessageId} + unreadCount={unreadCount} + onDelete={onDelete} + onEdit={onEdit} + onMarkUnread={onMarkUnread} + onMarkRead={onMarkRead} + onReply={timelineReplyHandler} + onOpenThread={isHuddleTranscript ? undefined : onOpenThread} + channelName={activeChannel?.name} + channelType={activeChannel?.channelType ?? null} + isSendingVideoReviewComment={isSending} + onSendVideoReviewComment={ + activeChannel?.archivedAt ? undefined : onSendVideoReviewComment + } + onTargetReached={onTargetReached} + onToggleReaction={onToggleReaction} + targetMessageId={targetMessageId} + splitThreadPanelOpen={ + useSplitAuxiliaryPane && + !useFocusThreadDrawer && + Boolean(openThreadHeadId) + } + threadUnreadCounts={threadUnreadCounts} + /> + {isNonMemberView ? (
- {isActiveWelcomeChannel && !timeoutState.active ? ( - - {welcomeKickoffStage} - - ) : null} - {timeoutState.active ? ( - + + + Viewing{" "} + + #{activeChannel?.name} + + +
+ +
+ ) : ( +
+ +
+ {isActiveWelcomeChannel && !timeoutState.active ? ( + + {welcomeKickoffStage} + + ) : null} + {timeoutState.active ? ( + + ) : null} + + - ) : null} - - - {/* The activity accessory is anchored in the dock's reserved + {/* The activity accessory is anchored in the dock's reserved bottom rail, so fading it cannot change the observed overlay height or move the conversation. Its natural content height remains responsive. */} - + +
-
- )} - {canDropInMainColumn && mainComposerMedia.isDragOver ? ( - - ) : null} + )} + {canDropInMainColumn && mainComposerMedia.isDragOver ? ( + + ) : null} +
) : null} diff --git a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx index d5e287c20a..1aaad0e609 100644 --- a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx +++ b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx @@ -129,10 +129,12 @@ const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const; * header's breadcrumb, where the eye already is — the sliver carries no label of * its own. * - * `z-41` puts the overlay above the channel timeline, its `z-40` composer - * overlay and the `z-30` shared header backdrop, while staying below the global - * `z-45` top chrome. Setting z-index on the positioned container also gives the - * drawer its own stacking context, so the panel chrome inside it is isolated. + * `z-41` places the drawer above the channel section (whose inner `isolate` + * wrapper traps the timeline's z-50 pill, z-40 composer overlay, and z-50 drop + * overlay) and the `z-30` shared header backdrop, while staying below the + * global `z-45` top chrome. Setting z-index on the positioned container also + * gives the drawer its own stacking context, so the panel chrome inside is + * isolated. */ export function FocusThreadDrawer({ channelName, From 569308c23c9c2bf620dd3a9a5e4baecbcfa22e16 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 20 Aug 2026 17:31:39 -0600 Subject: [PATCH 03/38] fix(desktop): restore recent channel sorting (#6402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - batch desktop last-message queries into groups of 128 so workspaces with larger channel counts stay within the relay's explicit-channel limit - propagate timestamp query failures instead of replacing the sidebar's cached recency with all-null data - advance channel recency from live message events so Recent ordering updates without waiting for the next refresh ## Root cause The desktop sent one explicit `#h` filter per channel in a single `/query`. The relay rejects a request with more than 128 aggregate explicit channel values. The desktop swallowed that rejection and returned no timestamps, so Recent correctly fell back to A–Z for every channel. ## Testing - `cargo test --manifest-path desktop/src-tauri/Cargo.toml last_message_filters_stay_within_relay_channel_cap --lib` - `cd desktop && pnpm test` (5,132 passed) - `cd desktop && pnpm exec biome check src/features/channels/useLiveChannelUpdates.ts src/features/channels/lib/channelRecency.ts src/features/channels/lib/channelRecency.test.mjs tests/e2e/channel-sort.spec.ts` - `cd desktop && pnpm check:file-sizes` - `cargo fmt --all -- --check` - `cd desktop && pnpm build:e2e` followed by `pnpm exec playwright test tests/e2e/channel-sort.spec.ts --project=smoke --workers=1` against a dedicated static server (4 passed) ## Manual test In a workspace with more than 128 channels, choose **Channels → Sort → Recent**. Channels should order by latest message instead of A–Z. While Recent is selected, a new message in a visible channel should move that channel to the top immediately. --------- Signed-off-by: Wes Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- .../src-tauri/src/commands/channels/fetch.rs | 62 ++++-- .../src-tauri/src/commands/channels_tests.rs | 29 +++ desktop/src/features/channels/hooks.test.mjs | 155 +++++++++++++- desktop/src/features/channels/hooks.ts | 196 +++++++++++------- .../channels/lib/channelRecency.test.mjs | 97 +++++++++ .../features/channels/lib/channelRecency.ts | 63 ++++++ .../channels/lib/channelRecencyMerge.ts | 39 ++++ .../channels/useLiveChannelUpdates.ts | 8 + desktop/src/testing/e2eBridge.ts | 6 +- desktop/tests/e2e/channel-sort.spec.ts | 110 ++++++++-- 10 files changed, 659 insertions(+), 106 deletions(-) create mode 100644 desktop/src/features/channels/lib/channelRecency.test.mjs create mode 100644 desktop/src/features/channels/lib/channelRecency.ts create mode 100644 desktop/src/features/channels/lib/channelRecencyMerge.ts diff --git a/desktop/src-tauri/src/commands/channels/fetch.rs b/desktop/src-tauri/src/commands/channels/fetch.rs index c41a707341..36c24a35b7 100644 --- a/desktop/src-tauri/src/commands/channels/fetch.rs +++ b/desktop/src-tauri/src/commands/channels/fetch.rs @@ -9,6 +9,12 @@ use crate::{app_state::AppState, models::ChannelInfo, nostr_convert, relay::query_relay}; pub(super) const DIRECTORY_PAGE_SIZE: usize = 500; +// Keep this aligned with the relay's aggregate explicit-`#h` request bound. +// Each filter carries one channel so the relay can use its channel_id index. +const LAST_MESSAGE_QUERY_CHANNEL_BATCH_SIZE: usize = 128; +// Human-visible channel activity that drives sidebar Recent ordering. Keep this +// aligned with desktop/src/shared/constants/kinds.ts::CHANNEL_MESSAGE_EVENT_KINDS. +const CHANNEL_RECENCY_EVENT_KINDS: [u16; 4] = [9, 40002, 45001, 45003]; pub(super) fn advance_directory_cursor(filter: &mut serde_json::Value, page: &[nostr::Event]) { let last = page @@ -129,6 +135,33 @@ pub(super) fn compute_channels_hash(channels: &[ChannelInfo]) -> String { // ── Core fetch implementation ───────────────────────────────────────────────── +pub(super) fn last_message_filter(channel_id: &str) -> serde_json::Value { + serde_json::json!({ + "kinds": CHANNEL_RECENCY_EVENT_KINDS, + "#h": [channel_id], + "limit": 1 + }) +} + +pub(super) fn last_message_filter_batches( + filters: &[serde_json::Value], +) -> Vec<&[serde_json::Value]> { + filters + .chunks(LAST_MESSAGE_QUERY_CHANNEL_BATCH_SIZE) + .collect() +} + +async fn query_last_messages( + state: &AppState, + filters: &[serde_json::Value], +) -> Result, String> { + let mut messages = Vec::with_capacity(filters.len()); + for batch in last_message_filter_batches(filters) { + messages.extend(query_relay(state, batch).await?); + } + Ok(messages) +} + /// Whether `fetch_channels` includes the unbounded all-open directory scan. /// /// The 60s channel poll uses [`DirectoryScope::MemberOnly`]: it resolves only @@ -154,7 +187,10 @@ pub(super) enum DirectoryScope { /// metadata source (pending-owned ids when member-only, else the all-open /// kind:39000 scan), and the hidden-DM snapshot (kind:30622). /// - Phase 2 (parallel): member counts (kind:39002 batch) and last-message -/// timestamps (per-channel kind:9/40002), fanned out over the merged set. +/// timestamps (bounded per-channel human-visible activity batches), fanned +/// out over the merged set. Member-count failures degrade to zero; timestamp +/// failures abort so cached recency is never replaced by a false +/// authoritative empty result. pub(super) async fn fetch_channels( state: &AppState, scope: DirectoryScope, @@ -336,19 +372,13 @@ pub(super) async fn fetch_channels( } // Phase 2 — concurrent: member counts (step 4) and last-message timestamps - // (step 5). Both tolerate failures — empty defaults leave counts at 0 and - // timestamps at None rather than aborting. + // (step 5). Member-count failures degrade to zero. Timestamp failures + // abort this refresh so the frontend keeps its previous Recent ordering. let all_channel_ids: Vec = channels.iter().map(|c| c.id.clone()).collect(); if !all_channel_ids.is_empty() { let last_msg_filters: Vec = all_channel_ids .iter() - .map(|id| { - serde_json::json!({ - "kinds": [9, 40002], - "#h": [id], - "limit": 1 - }) - }) + .map(|id| last_message_filter(id)) .collect(); // Bind both filter arrays before the join so their lifetimes cover @@ -361,10 +391,14 @@ pub(super) async fn fetch_channels( let (members_result, message_result) = tokio::join!( // Step 4: batch-fetch kind:39002 for member counts. query_relay(state, &member_count_filters), - // Step 5: per-channel last-message filter. Uses per-channel `#h` - // so the relay can push each query to its indexed channel_id column. - query_relay(state, &last_msg_filters), + // Step 5: preserve one indexed filter per channel while keeping + // every relay request within its aggregate explicit-channel cap. + query_last_messages(state, &last_msg_filters), ); + // Message timestamps drive the user-selected Recent ordering. Unlike + // member counts, a failed query must not masquerade as an authoritative + // empty result and clear every cached timestamp in the frontend. + let messages = message_result?; let membership = collect_members_by_channel(&members_result.unwrap_or_default()); for channel in &mut channels { @@ -376,7 +410,7 @@ pub(super) async fn fetch_channels( let mut last_message_by_channel: std::collections::HashMap = std::collections::HashMap::new(); - for ev in &message_result.unwrap_or_default() { + for ev in &messages { if let Some(ch_id) = ev.tags.iter().find_map(|t| { let s = t.as_slice(); (s.len() >= 2 && s[0] == "h").then(|| s[1].clone()) diff --git a/desktop/src-tauri/src/commands/channels_tests.rs b/desktop/src-tauri/src/commands/channels_tests.rs index e78d4498c8..fb43bb7a70 100644 --- a/desktop/src-tauri/src/commands/channels_tests.rs +++ b/desktop/src-tauri/src/commands/channels_tests.rs @@ -461,3 +461,32 @@ fn starter_match_requires_open_unarchived_stream_by_normalized_name() { channel.archived_at = Some("2026-07-16T00:00:00Z".to_string()); assert!(!is_matching_starter_channel(&channel, spec)); } + +#[test] +fn last_message_filter_covers_all_human_visible_activity_kinds() { + let filter = last_message_filter("forum-1"); + + assert_eq!( + filter, + serde_json::json!({ + "kinds": [9, 40002, 45001, 45003], + "#h": ["forum-1"], + "limit": 1 + }) + ); +} + +#[test] +fn last_message_filters_stay_within_relay_channel_cap() { + let filters: Vec = (0..257) + .map(|index| serde_json::json!({"#h": [format!("channel-{index}")]})) + .collect(); + + let batches = last_message_filter_batches(&filters); + + assert_eq!( + batches.iter().map(|batch| batch.len()).collect::>(), + [128, 128, 1] + ); + assert_eq!(batches.concat(), filters); +} diff --git a/desktop/src/features/channels/hooks.test.mjs b/desktop/src/features/channels/hooks.test.mjs index 7dee24392f..8efeed8536 100644 --- a/desktop/src/features/channels/hooks.test.mjs +++ b/desktop/src/features/channels/hooks.test.mjs @@ -1,10 +1,14 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { QueryClient } from "@tanstack/react-query"; + import { applyLastMessages, canFetchChannelsForIdentity, + channelsQueryKey, reconcileRefreshedCachedChannel, + refreshChannelsQuery, requireFullChannelList, upsertCachedChannel, upsertCachedChannelMember, @@ -14,7 +18,7 @@ function makeChannel( id, name, channelType = "stream", - { participantPubkeys = [], participants = [] } = {}, + { participantPubkeys = [], participants = [], lastMessageAt = null } = {}, ) { return { id, @@ -26,7 +30,7 @@ function makeChannel( purpose: null, memberCount: participantPubkeys.length, memberPubkeys: [...participantPubkeys], - lastMessageAt: null, + lastMessageAt, archivedAt: null, participants, participantPubkeys, @@ -36,6 +40,153 @@ function makeChannel( }; } +function deferred() { + let resolve; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function makeRefreshHarness({ cachedHash = "hash-1" } = {}) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const start = makeChannel("general", "General", "stream", { + lastMessageAt: "2026-01-01T00:00:00.000Z", + }); + queryClient.setQueryData(channelsQueryKey, [start]); + const request = deferred(); + const calls = []; + const fetchChannels = (knownHash) => { + calls.push(knownHash); + return request.promise; + }; + const initialSnapshotPair = cachedHash + ? { channels: [start], hash: cachedHash } + : null; + + return { + calls, + fetchChannels, + initialSnapshotPair, + queryClient, + request, + start, + }; +} + +function setDisplayedRecency(queryClient, lastMessageAt) { + queryClient.setQueryData(channelsQueryKey, (channels) => + channels.map((channel) => + channel.id === "general" ? { ...channel, lastMessageAt } : channel, + ), + ); +} + +function refreshWithHarness(harness, fetchChannels = harness.fetchChannels) { + return harness.queryClient.fetchQuery({ + queryKey: channelsQueryKey, + queryFn: () => + refreshChannelsQuery({ + queryClient: harness.queryClient, + initialSnapshotPair: harness.initialSnapshotPair, + relayUrl: null, + ownerPubkey: null, + fetchChannels, + }), + }); +} + +const T1 = "2026-01-01T00:01:00.000Z"; +const T2 = "2026-01-01T00:02:00.000Z"; + +test("refreshChannelsQuery preserves a live update through matching not-modified settlement", async () => { + const harness = makeRefreshHarness(); + const refresh = refreshWithHarness(harness); + + assert.deepEqual(harness.calls, ["hash-1"]); + setDisplayedRecency(harness.queryClient, T2); + harness.request.resolve({ + hash: "hash-1", + channels: null, + lastMessages: { general: T1 }, + }); + + const result = await refresh; + assert.equal(result[0].lastMessageAt, T2); + assert.equal( + harness.queryClient.getQueryData(channelsQueryKey)[0].lastMessageAt, + T2, + ); +}); + +test("refreshChannelsQuery preserves a live update through authoritative full-list settlement", async () => { + const harness = makeRefreshHarness({ cachedHash: null }); + const refresh = refreshWithHarness(harness); + + assert.deepEqual(harness.calls, [null]); + setDisplayedRecency(harness.queryClient, T2); + harness.request.resolve({ + hash: "hash-2", + channels: [makeChannel("general", "General")], + lastMessages: { general: T1 }, + }); + + const result = await refresh; + assert.equal(result[0].lastMessageAt, T2); + assert.equal( + harness.queryClient.getQueryData(channelsQueryKey)[0].lastMessageAt, + T2, + ); +}); + +test("refreshChannelsQuery preserves a live update through mismatched not-modified retry", async () => { + const harness = makeRefreshHarness(); + const retry = deferred(); + const fetchChannels = (knownHash) => { + harness.calls.push(knownHash); + return harness.calls.length === 1 + ? Promise.resolve({ + hash: "mismatched-hash", + channels: null, + lastMessages: {}, + }) + : retry.promise; + }; + const refresh = refreshWithHarness(harness, fetchChannels); + + await Promise.resolve(); + assert.deepEqual(harness.calls, ["hash-1", null]); + setDisplayedRecency(harness.queryClient, T2); + retry.resolve({ + hash: "hash-2", + channels: [makeChannel("general", "General")], + lastMessages: { general: T1 }, + }); + + const result = await refresh; + assert.equal(result[0].lastMessageAt, T2); + assert.equal( + harness.queryClient.getQueryData(channelsQueryKey)[0].lastMessageAt, + T2, + ); +}); + +test("refreshChannelsQuery clears unchanged recency on authoritative absence", async () => { + const harness = makeRefreshHarness(); + const refresh = refreshWithHarness(harness); + + harness.request.resolve({ + hash: "hash-1", + channels: null, + lastMessages: {}, + }); + + const result = await refresh; + assert.equal(result[0].lastMessageAt, null); +}); + test("upsertCachedChannel_reseedsOpenedDmAfterStaleRefetch", () => { const staleChannels = [makeChannel("general", "General")]; const openedDm = makeChannel("new-dm", "Alice", "dm"); diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 007ffeef44..9f612031f3 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -1,5 +1,10 @@ import * as React from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useMutation, + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; import { addChannelMembers, @@ -31,7 +36,11 @@ import type { SetChannelTopicInput, UpdateChannelInput, } from "@/shared/api/types"; -import type { OpenDmInput } from "@/shared/api/tauriChannels"; +import type { + GetChannelsPayload, + OpenDmInput, +} from "@/shared/api/tauriChannels"; +import { mergeConcurrentChannelRecency } from "@/features/channels/lib/channelRecencyMerge"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; import { useCommunities } from "@/features/communities/useCommunities"; @@ -312,6 +321,113 @@ export function requireFullChannelList(channels: Channel[] | null): Channel[] { return channels; } +export type RefreshChannelsQueryOptions = { + queryClient: QueryClient; + initialSnapshotPair: ChannelSnapshot | null; + relayUrl: string | null; + ownerPubkey: string | null; + fetchChannels?: (knownHash: string | null) => Promise; + persistSnapshot?: typeof writeChannelSnapshot; +}; + +/** + * Revalidates the channel query while preserving live recency updates that land + * during the request. Exported so the production query/cache interleaving can + * be regression-tested without replacing it with a helper-only simulation. + */ +export async function refreshChannelsQuery({ + queryClient, + initialSnapshotPair, + relayUrl, + ownerPubkey, + fetchChannels = getChannels, + persistSnapshot = writeChannelSnapshot, +}: RefreshChannelsQueryOptions): Promise { + // Revalidation uses only an authoritative list/hash pair. The displayed + // channels cache is intentionally ignored because successful mutations + // patch it before the relay's list/hash has necessarily caught up. + const cachedPair = + queryClient.getQueryData(channelsSnapshotPairKey) ?? + initialSnapshotPair; + const knownHash = cachedPair?.hash ?? null; + + const channelsAtRequestStart = + queryClient.getQueryData(channelsQueryKey); + const payload = await fetchChannels(knownHash); + + // A not-modified response is usable only when it echoes the exact hash + // that described the available list. Any other hash/list pairing fails + // slow-never-wrong by retrying without a hash. + const hasMatchingNotModifiedResponse = + payload.channels === null && + knownHash !== null && + payload.hash === knownHash; + const pairChannels = + payload.channels ?? + (hasMatchingNotModifiedResponse ? cachedPair?.channels : undefined); + + if (!pairChannels) { + // Missing cache or a mismatched not-modified response: discard the hash + // and fetch a complete authoritative list before updating persistence. + const full = await fetchChannels(null); + const authoritativeChannels = sortChannels( + applyLastMessages( + requireFullChannelList(full.channels), + full.lastMessages, + ), + ); + const displayedAtSettlement = + queryClient.getQueryData(channelsQueryKey); + const sorted = sortChannels( + mergeConcurrentChannelRecency( + authoritativeChannels, + displayedAtSettlement, + channelsAtRequestStart, + ), + ); + const pair = { channels: authoritativeChannels, hash: full.hash }; + queryClient.setQueryData(channelsSnapshotPairKey, pair); + if (relayUrl && ownerPubkey) { + persistSnapshot(relayUrl, ownerPubkey, pair.channels, pair.hash); + } + return sorted; + } + + const authoritativeChannels = sortChannels( + applyLastMessages(pairChannels, payload.lastMessages), + ); + const pair = { + channels: authoritativeChannels, + hash: payload.hash, + }; + queryClient.setQueryData(channelsSnapshotPairKey, pair); + // Merge against the displayed cache at settlement so a newer live + // timestamp cannot be rolled back by an older request result. This is + // required for both full-list and matching not-modified responses. + const displayedAtSettlement = + queryClient.getQueryData(channelsQueryKey); + const refreshedForDisplay = + payload.channels === null + ? sortChannels( + applyLastMessages( + displayedAtSettlement ?? authoritativeChannels, + payload.lastMessages, + ), + ) + : authoritativeChannels; + const sorted = sortChannels( + mergeConcurrentChannelRecency( + refreshedForDisplay, + displayedAtSettlement, + channelsAtRequestStart, + ), + ); + if (relayUrl && ownerPubkey) { + persistSnapshot(relayUrl, ownerPubkey, pair.channels, pair.hash); + } + return sorted; +} + export function useChannelsQuery(options?: { enabled?: boolean }) { const { activeCommunity } = useCommunities(); const relayUrl = activeCommunity?.relayUrl ?? null; @@ -351,75 +467,13 @@ export function useChannelsQuery(options?: { enabled?: boolean }) { relayUrl !== null && canFetchChannelsForIdentity(ownerPubkey, identityQuery.isError), queryKey: channelsQueryKey, - queryFn: async () => { - // Revalidation uses only an authoritative list/hash pair. The displayed - // channels cache is intentionally ignored because successful mutations - // patch it before the relay's list/hash has necessarily caught up. - const cachedPair = - queryClient.getQueryData(channelsSnapshotPairKey) ?? - initialSnapshotPair; - const knownHash = cachedPair?.hash ?? null; - - const payload = await getChannels(knownHash); - - // A not-modified response is usable only when it echoes the exact hash - // that described the available list. Any other hash/list pairing fails - // slow-never-wrong by retrying without a hash. - const hasMatchingNotModifiedResponse = - payload.channels === null && - knownHash !== null && - payload.hash === knownHash; - const pairChannels = - payload.channels ?? - (hasMatchingNotModifiedResponse ? cachedPair?.channels : undefined); - - if (!pairChannels) { - // Missing cache or a mismatched not-modified response: discard the hash - // and fetch a complete authoritative list before updating persistence. - const full = await getChannels(null); - const sorted = sortChannels( - applyLastMessages( - requireFullChannelList(full.channels), - full.lastMessages, - ), - ); - const pair = { channels: sorted, hash: full.hash }; - queryClient.setQueryData(channelsSnapshotPairKey, pair); - if (relayUrl && ownerPubkey) { - writeChannelSnapshot(relayUrl, ownerPubkey, pair.channels, pair.hash); - } - return sorted; - } - - const authoritativeChannels = sortChannels( - applyLastMessages(pairChannels, payload.lastMessages), - ); - const pair = { - channels: authoritativeChannels, - hash: payload.hash, - }; - queryClient.setQueryData(channelsSnapshotPairKey, pair); - // A matching not-modified result must merge timestamps into whatever is - // displayed at completion time. Reading through setQueryData avoids - // clobbering an optimistic mutation that landed while the request ran. - const sorted = - payload.channels === null - ? (queryClient.setQueryData( - channelsQueryKey, - (displayedChannels) => - sortChannels( - applyLastMessages( - displayedChannels ?? authoritativeChannels, - payload.lastMessages, - ), - ), - ) ?? authoritativeChannels) - : authoritativeChannels; - if (relayUrl && ownerPubkey) { - writeChannelSnapshot(relayUrl, ownerPubkey, pair.channels, pair.hash); - } - return sorted; - }, + queryFn: () => + refreshChannelsQuery({ + queryClient, + initialSnapshotPair, + relayUrl, + ownerPubkey, + }), // Paint the complete persisted list immediately. `initialDataUpdatedAt: 0` // deliberately keeps it stale so every boot still validates against the // relay; queryFn reads the matching hash from the same atomic document. diff --git a/desktop/src/features/channels/lib/channelRecency.test.mjs b/desktop/src/features/channels/lib/channelRecency.test.mjs new file mode 100644 index 0000000000..30e57714c1 --- /dev/null +++ b/desktop/src/features/channels/lib/channelRecency.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { applyChannelLastMessageAt } from "./channelRecency.ts"; +import { mergeConcurrentChannelRecency } from "./channelRecencyMerge.ts"; + +function makeChannel(id, lastMessageAt = null) { + return { id, lastMessageAt }; +} + +test("applyChannelLastMessageAt advances only the matching channel", () => { + const general = makeChannel("general", "2026-01-01T00:00:00.000Z"); + const design = makeChannel("design", "2026-01-01T00:00:00.000Z"); + + const result = applyChannelLastMessageAt( + [general, design], + "design", + 1_767_225_660, + ); + + assert.notStrictEqual(result, undefined); + assert.strictEqual(result[0], general); + assert.notStrictEqual(result[1], design); + assert.equal(result[1].lastMessageAt, "2026-01-01T00:01:00.000Z"); +}); + +test("applyChannelLastMessageAt ignores stale or equal timestamps", () => { + const design = makeChannel("design", "2026-01-01T00:01:00.000Z"); + const channels = [design]; + + assert.strictEqual( + applyChannelLastMessageAt(channels, "design", 1_767_225_600), + channels, + ); + assert.strictEqual( + applyChannelLastMessageAt(channels, "design", "2026-01-01T00:01:00.000Z"), + channels, + ); +}); + +test("applyChannelLastMessageAt preserves the list for invalid or unknown updates", () => { + const channels = [makeChannel("design")]; + + assert.strictEqual( + applyChannelLastMessageAt(channels, "design", "not-a-date"), + channels, + ); + assert.strictEqual( + applyChannelLastMessageAt(channels, "unknown", 1_767_225_660), + channels, + ); + assert.strictEqual( + applyChannelLastMessageAt(undefined, "design", 1_767_225_660), + undefined, + ); +}); + +function mergeRecency(start, displayed, refreshed) { + return mergeConcurrentChannelRecency( + [makeChannel("general", refreshed)], + [makeChannel("general", displayed)], + [makeChannel("general", start)], + )[0]; +} + +test("mergeConcurrentChannelRecency preserves a newer live timestamp", () => { + const result = mergeRecency( + "2026-01-01T00:00:00Z", + "2026-01-01T00:02:00Z", + "2026-01-01T00:01:00Z", + ); + assert.equal(result.lastMessageAt, "2026-01-01T00:02:00Z"); +}); + +test("mergeConcurrentChannelRecency preserves monotonic and absence semantics", () => { + assert.equal( + mergeRecency( + "2026-01-01T00:02:00Z", + "2026-01-01T00:02:00Z", + "2026-01-01T00:01:00Z", + ).lastMessageAt, + "2026-01-01T00:02:00Z", + ); + assert.equal( + mergeRecency("2026-01-01T00:01:00Z", "2026-01-01T00:01:00Z", null) + .lastMessageAt, + null, + ); + assert.equal( + mergeRecency( + "2026-01-01T00:00:00Z", + "2026-01-01T00:01:00Z", + "2026-01-01T00:02:00Z", + ).lastMessageAt, + "2026-01-01T00:02:00Z", + ); +}); diff --git a/desktop/src/features/channels/lib/channelRecency.ts b/desktop/src/features/channels/lib/channelRecency.ts new file mode 100644 index 0000000000..30cc162504 --- /dev/null +++ b/desktop/src/features/channels/lib/channelRecency.ts @@ -0,0 +1,63 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { channelsQueryKey } from "@/features/channels/hooks"; +import type { Channel } from "@/shared/api/types"; + +function parseTimestamp(value: number | string | null | undefined) { + if (typeof value === "number") { + return Number.isFinite(value) ? value * 1_000 : null; + } + + if (!value) { + return null; + } + + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? null : timestamp; +} + +export function applyChannelLastMessageAt( + current: Channel[] | undefined, + channelId: string, + lastMessageAt: number | string | null | undefined, +): Channel[] | undefined { + if (!current) { + return current; + } + + const candidateTimestamp = parseTimestamp(lastMessageAt); + if (candidateTimestamp === null) { + return current; + } + + let didUpdate = false; + const normalizedLastMessageAt = new Date(candidateTimestamp).toISOString(); + const nextChannels = current.map((channel) => { + if (channel.id !== channelId) { + return channel; + } + + const currentTimestamp = parseTimestamp(channel.lastMessageAt); + if (currentTimestamp !== null && candidateTimestamp <= currentTimestamp) { + return channel; + } + + didUpdate = true; + return { + ...channel, + lastMessageAt: normalizedLastMessageAt, + }; + }); + + return didUpdate ? nextChannels : current; +} + +export function updateChannelLastMessageAt( + queryClient: QueryClient, + channelId: string, + lastMessageAt: number | string | null | undefined, +) { + queryClient.setQueryData(channelsQueryKey, (current) => + applyChannelLastMessageAt(current, channelId, lastMessageAt), + ); +} diff --git a/desktop/src/features/channels/lib/channelRecencyMerge.ts b/desktop/src/features/channels/lib/channelRecencyMerge.ts new file mode 100644 index 0000000000..50cfd0ea45 --- /dev/null +++ b/desktop/src/features/channels/lib/channelRecencyMerge.ts @@ -0,0 +1,39 @@ +export type RecencyChannel = { + id: string; + lastMessageAt: string | null; +}; + +function timestamp(value: string | null | undefined): number | null { + if (!value) return null; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +/** + * Keeps recency monotonic when a refresh settles. An authoritative absence may + * clear an unchanged value, but never a live value added during the request. + */ +export function mergeConcurrentChannelRecency( + refreshed: T[], + displayed: T[] | undefined, + atRequestStart: T[] | undefined, +): T[] { + if (!displayed) return refreshed; + const displayedById = new Map(displayed.map((c) => [c.id, c.lastMessageAt])); + const startById = new Map( + atRequestStart?.map((c) => [c.id, c.lastMessageAt]) ?? [], + ); + + return refreshed.map((channel) => { + const displayedValue = displayedById.get(channel.id); + const displayedAt = timestamp(displayedValue); + const refreshedAt = timestamp(channel.lastMessageAt); + const changed = displayedValue !== startById.get(channel.id); + const keepDisplayed = + displayedAt !== null && + (refreshedAt !== null ? displayedAt > refreshedAt : changed); + return keepDisplayed + ? { ...channel, lastMessageAt: displayedValue ?? null } + : channel; + }); +} diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index 800467b6ea..7598e8db3e 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { channelsQueryKey } from "@/features/channels/hooks"; +import { updateChannelLastMessageAt } from "@/features/channels/lib/channelRecency"; import { mergeTimelineCacheMessages } from "@/features/messages/hooks"; import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; import { @@ -247,6 +248,13 @@ export function useLiveChannelUpdates( isDmChannel, ); + // Recency is presentation state, not notification state. Every recognized + // message advances Recent ordering, including self-authored and muted + // messages that the notification policy deliberately filters below. + if (isUnreadTriggerKind) { + updateChannelLastMessageAt(queryClient, channelId, event.created_at); + } + // Let the caller observe self-authored trigger events (e.g. to track // thread participation) before the author-exclusion guard filters them. if ( diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index f04959306d..a23d996a6e 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2862,7 +2862,9 @@ const mockChannels: MockChannel[] = [ description: "Casual forum for async discussions", topic: null, purpose: null, - last_message_at: null, + // Authoritative forum-post recency returned by get_channels. This must come + // from the native 45001/45003 query, not the stream-only 9/40002 query. + last_message_at: isoMinutesAgo(10), archived_at: null, created_by: ALICE_PUBKEY, topic_set_by: null, @@ -2873,7 +2875,7 @@ const mockChannels: MockChannel[] = [ max_members: null, nip29_group_id: null, created_minutes_ago: 900, - updated_minutes_ago: 900, + updated_minutes_ago: 10, members: [ createMockMember(ALICE_PUBKEY, "owner", 900), createMockMember(MOCK_IDENTITY_PUBKEY, "member", 750), diff --git a/desktop/tests/e2e/channel-sort.spec.ts b/desktop/tests/e2e/channel-sort.spec.ts index c267a03b32..da70147032 100644 --- a/desktop/tests/e2e/channel-sort.spec.ts +++ b/desktop/tests/e2e/channel-sort.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"; import type { Page } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; -import { installMockBridge } from "../helpers/bridge"; +import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; const SHOTS = "test-results/channel-sort"; @@ -28,6 +28,58 @@ async function openApp(page: Page) { await expect(page.getByTestId("chat-title")).toHaveText("general"); } +async function waitForMockLiveSubscription(page: Page, channelName: string) { + await expect + .poll(() => + page.evaluate( + (name) => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: name, + }) ?? false, + channelName, + ), + ) + .toBe(true); +} + +async function emitMockMessage( + page: Page, + channelName: string, + createdAt: number, +) { + await page.evaluate( + ({ channelName, createdAt, pubkey }) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) { + throw new Error("Mock message emitter is not installed"); + } + emit({ + channelName, + content: "Fresh activity for Recent ordering", + createdAt, + pubkey, + }); + }, + { channelName, createdAt, pubkey: TEST_IDENTITIES.alice.pubkey }, + ); +} + +function forumNames(page: Page) { + return page + .getByTestId("forum-list") + .locator("[data-testid^='channel-']") + .evaluateAll((nodes) => + nodes + .map((n) => n.getAttribute("data-testid") ?? "") + .filter( + (id) => + !id.startsWith("channel-unread") && + !id.startsWith("channel-working"), + ) + .map((id) => id.replace(/^channel-/, "")), + ); +} + function streamNames(page: Page) { return page .getByTestId("stream-list") @@ -61,6 +113,14 @@ test.describe("per-group channel sort", () => { await installMockBridge(page); await openApp(page); + // Prime a live subscription while A–Z keeps engineering rendered, then move + // away so the later message exercises sidebar recency rather than routing. + await page.getByTestId("channel-engineering").click(); + await expect(page.getByTestId("chat-title")).toHaveText("engineering"); + await waitForMockLiveSubscription(page, "engineering"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + // Hover the Channels header to reveal the action cluster, then open the // sort dropdown and choose Recent. const streamList = page.getByTestId("stream-list"); @@ -102,6 +162,18 @@ test.describe("per-group channel sort", () => { await waitForAnimations(page); await page.screenshot({ path: `${SHOTS}/03-channels-recent.png` }); + // A fresh message must advance the matching channel immediately. The mock + // fixture intentionally pins all-replies in 2999, so use 3000 to prove the + // live cache update outranks even that deterministic sentinel. + await emitMockMessage( + page, + "engineering", + Math.floor(Date.parse("3000-01-01T00:00:00.000Z") / 1_000), + ); + await expect + .poll(async () => (await streamNames(page)).slice(0, 3)) + .toEqual(["engineering", "all-replies", "deep-history"]); + // Persisted for this identity. const stored = await page.evaluate((key) => { return JSON.parse(window.localStorage.getItem(key) ?? "null"); @@ -129,26 +201,30 @@ test.describe("per-group channel sort", () => { .toEqual(["all-replies", "deep-history"]); // …while Forums (unset) stays alphabetical. - const forumNames = await page - .getByTestId("forum-list") - .locator("[data-testid^='channel-']") - .evaluateAll((nodes) => - nodes - .map((n) => n.getAttribute("data-testid") ?? "") - .filter( - (id) => - !id.startsWith("channel-unread") && - !id.startsWith("channel-working"), - ) - .map((id) => id.replace(/^channel-/, "")), - ); - const sortedForums = [...forumNames].sort((a, b) => a.localeCompare(b)); - expect(forumNames).toEqual(sortedForums); + const forumOrder = await forumNames(page); + const sortedForums = [...forumOrder].sort((a, b) => a.localeCompare(b)); + expect(forumOrder).toEqual(sortedForums); await waitForAnimations(page); await page.screenshot({ path: `${SHOTS}/04-independent-groups.png` }); }); - test("04 — DM group has its own sort trigger", async ({ page }) => { + test("04 — Forum Recent survives authoritative reload", async ({ page }) => { + await seedSortState(page, { forums: "recent" }); + await installMockBridge(page); + await openApp(page); + + await expect + .poll(async () => (await forumNames(page)).slice(0, 2)) + .toEqual(["watercooler", "announcements"]); + + await page.reload(); + await expect(page.getByTestId("forum-list")).toBeVisible(); + await expect + .poll(async () => (await forumNames(page)).slice(0, 2)) + .toEqual(["watercooler", "announcements"]); + }); + + test("05 — DM group has its own sort trigger", async ({ page }) => { await installMockBridge(page); await openApp(page); From c63ac746cd4622e043574c305ff54021b07b847b Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 20 Aug 2026 19:44:05 -0400 Subject: [PATCH 04/38] perf(desktop): split discover_acp_providers into cheap and forced paths (#6330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the `discover_acp_providers` Tauri command into a cheap cached path and an explicit forced re-discovery path. The profiling flight-recorder identified `discover_acp_providers` as the acute UI-freeze correlate: a 20–65s probe pipeline (PATH re-fetch, CLI auth probes, process spawns) was running on routine renders like channel switches, because hot surfaces remounted the wrapping query and automatic staleness refetches re-ran the full pipeline. This is complementary to the idle auto-reload (#5588) and bounded-accumulator (#5596) work. ## Backend - Added a `force` parameter to `discover_acp_providers` / `discover_acp_runtimes_from`. The default (cheap) path serves cached availability and auth statuses with zero process spawns; `force: true` runs the full pipeline (cache clear, PATH re-fetch, CLI auth probes). - Added a single-flight coalescer (`forced_single_flight`) so concurrent forced runs share one probe instead of stacking. - Extracted the auth-status cache and `resolve_auth_statuses` into `discovery/auth_status_cache.rs`. ## Renderer - Raised `staleTime` to 30 min; forced refresh flows only through an explicit `fetchQuery` path (`refreshAcpRuntimes`), so automatic staleness refetches can never re-run probes. - Added `useAcpRuntimesQueryForced` for Settings and onboarding surfaces; connect/install/save/delete mutations force-refresh in `onSettled`. - Switched `SetupStep`, `HarnessCatalogDialog`, and `HarnessesSettingsPanel` to the forced hook. ## Structure The touched files were at or over the grandfathered 1000-line file-size ceiling, so the change is delivered as cohesive extractions rather than in-place growth: `forced_single_flight.rs`, `auth_status_cache.rs`, `discovery/tests/forced_discovery.rs`, `acpRuntimesQuery.ts`, and `tauriAcpDiscovery.ts`. Dependencies are one-directional (`hooks.ts` imports from `acpRuntimesQuery.ts` and re-exports the forced hook, so consumer imports are unchanged; no import cycle). ## Tests Added Rust tests pinning the cheap-vs-forced split: a probe is spawned only when forced, the cheap path reuses the cache, and auth status is `Unknown` before any probe runs. --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- .../src-tauri/src/commands/agent_discovery.rs | 19 +- .../agent_discovery/forced_single_flight.rs | 80 +++ .../src-tauri/src/managed_agents/discovery.rs | 350 +++------- .../discovery/auth_status_cache.rs | 105 +++ .../managed_agents/discovery/login_shell.rs | 236 +++++++ .../discovery/login_shell_spawn_probe.rs | 21 + .../src/managed_agents/discovery/presets.rs | 2 +- .../src/managed_agents/discovery/tests.rs | 37 +- .../discovery/tests/forced_discovery.rs | 163 +++++ .../tests/managed_path_resolution.rs | 99 +++ .../src/managed_agents/runtime/cli_tests.rs | 38 ++ .../src/managed_agents/runtime/tests.rs | 33 +- .../features/agents/acpRuntimesQuery.test.mjs | 491 +++++++++++++ .../src/features/agents/acpRuntimesQuery.ts | 135 ++++ desktop/src/features/agents/hooks.ts | 34 +- .../ui/SetupStep.acpForcedGate.test.mjs | 433 ++++++++++++ .../src/features/onboarding/ui/SetupStep.tsx | 65 +- .../src/features/onboarding/welcomeGuide.ts | 2 +- ...arnessCatalogDialog.acpForcedGate.test.mjs | 646 ++++++++++++++++++ .../settings/ui/HarnessCatalogDialog.tsx | 63 +- .../settings/ui/HarnessesSettingsPanel.tsx | 6 +- desktop/src/shared/api/tauri.ts | 6 - desktop/src/shared/api/tauriAcpDiscovery.ts | 24 + 23 files changed, 2716 insertions(+), 372 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_discovery/forced_single_flight.rs create mode 100644 desktop/src-tauri/src/managed_agents/discovery/auth_status_cache.rs create mode 100644 desktop/src-tauri/src/managed_agents/discovery/login_shell.rs create mode 100644 desktop/src-tauri/src/managed_agents/discovery/login_shell_spawn_probe.rs create mode 100644 desktop/src-tauri/src/managed_agents/discovery/tests/forced_discovery.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime/cli_tests.rs create mode 100644 desktop/src/features/agents/acpRuntimesQuery.test.mjs create mode 100644 desktop/src/features/agents/acpRuntimesQuery.ts create mode 100644 desktop/src/features/onboarding/ui/SetupStep.acpForcedGate.test.mjs create mode 100644 desktop/src/features/settings/ui/HarnessCatalogDialog.acpForcedGate.test.mjs create mode 100644 desktop/src/shared/api/tauriAcpDiscovery.ts diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 95534854a0..95e9759f10 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -4,6 +4,7 @@ use crate::managed_agents::{ DEFAULT_ACP_COMMAND, }; +mod forced_single_flight; mod post_install_verification; fn active_installs() -> &'static std::sync::Mutex> { @@ -49,23 +50,15 @@ pub(crate) fn plan_adapter_install<'c>( } } +/// Discover the ACP runtime catalog. `force: false` (the default) serves the +/// cheap cached path; `force: true` runs the expensive re-discovery. See +/// [`forced_single_flight`] for the split and single-flight coalescing. #[tauri::command] pub async fn discover_acp_providers( app: tauri::AppHandle, + force: Option, ) -> Result, String> { - tokio::task::spawn_blocking(move || { - use tauri::Manager; - crate::managed_agents::clear_resolve_cache(); - crate::managed_agents::refresh_login_shell_path(); - let custom_dir = app - .path() - .app_data_dir() - .ok() - .map(|d| d.join("custom_harnesses")); - crate::managed_agents::discover_acp_runtimes_from(custom_dir.as_deref()) - }) - .await - .map_err(|e| format!("spawn_blocking failed: {e}")) + forced_single_flight::discover(app, force.unwrap_or(false)).await } /// Write a user-defined harness definition to `/custom_harnesses/.json`. diff --git a/desktop/src-tauri/src/commands/agent_discovery/forced_single_flight.rs b/desktop/src-tauri/src/commands/agent_discovery/forced_single_flight.rs new file mode 100644 index 0000000000..3667d3b237 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/forced_single_flight.rs @@ -0,0 +1,80 @@ +//! Discovery execution + single-flight coalescing for the ACP runtime catalog. +//! +//! `force: false` serves from the process caches (no clear, no PATH re-fetch, no +//! CLI auth probes) — the low-millisecond path hot surfaces render from. +//! +//! `force: true` runs the expensive probe pipeline. React Query already dedups +//! the hook consumers; the single-flight here is the seatbelt for non-hook +//! invoke paths, so a burst of forced triggers coalesces onto one in-flight run +//! instead of stacking the pipeline. + +use super::AcpRuntimeCatalogEntry; + +type BoxedDiscovery = std::pin::Pin< + Box, String>> + Send>, +>; +type SharedDiscovery = futures_util::future::Shared; + +fn inflight() -> &'static std::sync::Mutex> { + use std::sync::{Mutex, OnceLock}; + static INFLIGHT: OnceLock>> = OnceLock::new(); + INFLIGHT.get_or_init(|| Mutex::new(None)) +} + +/// Discover the ACP runtime catalog. Cheap calls run directly; forced calls +/// coalesce onto a single shared run (see module docs). +pub(super) async fn discover( + app: tauri::AppHandle, + force: bool, +) -> Result, String> { + if !force { + return run(app, false).await; + } + + let shared = { + let mut guard = inflight().lock().unwrap_or_else(|e| e.into_inner()); + match guard.as_ref() { + Some(existing) => existing.clone(), + None => { + let fut: BoxedDiscovery = Box::pin(run(app, true)); + let shared = futures_util::FutureExt::shared(fut); + *guard = Some(shared.clone()); + shared + } + } + }; + + let result = shared.clone().await; + + // Clear the slot so the next forced call re-runs — but only if it still + // points at the future we just awaited (a newer run may have replaced it). + { + let mut guard = inflight().lock().unwrap_or_else(|e| e.into_inner()); + if guard + .as_ref() + .is_some_and(|current| current.ptr_eq(&shared)) + { + *guard = None; + } + } + + result +} + +async fn run(app: tauri::AppHandle, force: bool) -> Result, String> { + tokio::task::spawn_blocking(move || { + use tauri::Manager; + if force { + crate::managed_agents::clear_resolve_cache(); + crate::managed_agents::refresh_login_shell_path(); + } + let custom_dir = app + .path() + .app_data_dir() + .ok() + .map(|d| d.join("custom_harnesses")); + crate::managed_agents::discover_acp_runtimes_from(custom_dir.as_deref(), force) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}")) +} diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index bc0e3a6cda..78592357c9 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -9,10 +9,18 @@ use crate::managed_agents::{ AcpAvailabilityStatus, AcpRuntimeCatalogEntry, AuthStatus, CommandAvailabilityInfo, HarnessSource, }; +mod auth_status_cache; +mod login_shell; mod presets; mod runtime_metadata; #[macro_use] mod windows_install; +pub use login_shell::{find_nvm_default_bin, login_shell_path}; +pub(crate) use login_shell::{find_via_login_shell, refresh_login_shell_path}; +#[cfg(test)] +pub(crate) use login_shell::{ + is_login_shell_path_uninit, is_safe_nvm_tag, login_shell_candidates, parse_semver_tag, +}; pub(crate) use presets::{ canonical_harness_command, command_for_runtime_id, preset_harness_definitions, preset_harness_ids, @@ -558,18 +566,40 @@ pub fn resolve_command(command: &str) -> Option { } } - // Slow path: resolve and cache. + // Slow path: resolve and cache. Negative results are cached too: an absent + // command must not re-run `resolve_command_uncached` (which spawns a login + // shell via `find_via_login_shell`) on every cheap discovery — that spawn + // on the channel-switch/composer hot path is exactly what this cache exists + // to prevent. `clear_resolve_cache` (run by every forced discovery) is the + // invalidation seam, so a newly-installed binary is still found on refresh. let result = resolve_command_uncached(command); - if result.is_some() { - if let Ok(mut guard) = cache.lock() { - guard.insert(command.to_string(), result.clone()); - } + if let Ok(mut guard) = cache.lock() { + guard.insert(command.to_string(), result.clone()); } result } +/// Cache-only command resolution for the cheap discovery path. +/// +/// Consults the Buzz-managed shim dir (a filesystem stat, never a spawn) and +/// the resolve cache; on a miss it reports the command absent rather than +/// resolving live via `resolve_command_uncached` → `find_via_login_shell`, +/// which spawns a login shell on the channel-switch / composer hot path — the +/// freeze the cheap path exists to avoid. `resolve_command` (the forced path) +/// is the sole prober and cache populator. +pub fn resolve_command_cached(command: &str) -> Option { + if let Some(managed) = resolve_buzz_managed_command(command) { + return Some(managed); + } + resolve_cache() + .lock() + .ok() + .and_then(|guard| guard.get(command).cloned()) + .flatten() +} + /// Clear the resolve_command cache so that newly-installed binaries are detected. pub fn clear_resolve_cache() { let mut guard = resolve_cache().lock().unwrap_or_else(|e| e.into_inner()); @@ -577,6 +607,9 @@ pub fn clear_resolve_cache() { // Also invalidate the adapter-availability cache so a freshly-installed // adapter is reflected the next time the summary builder checks the badge. clear_adapter_availability_cache(); + // And the auth-status cache so a forced re-discovery re-probes rather than + // reusing stale login state. + auth_status_cache::clear(); } // ── Adapter availability cache (Phase-2 badge fallback) ───────────────────── @@ -757,222 +790,10 @@ fn path_candidates_from_env_raw(basename: &str) -> Vec { .unwrap_or_default() } -/// Collect login shell candidates for the current platform. -/// -/// On Unix: `/bin/zsh`, `/bin/bash` (the historical defaults). -/// On Windows: Git Bash via `resolve_bash_path` — skips `BUZZ_SHELL` because -/// login-shell callers use bash-only `-l -c` syntax. -fn login_shell_candidates() -> Vec { - #[cfg(not(windows))] - { - vec![PathBuf::from("/bin/zsh"), PathBuf::from("/bin/bash")] - } - #[cfg(windows)] - { - super::git_bash::resolve_bash_path().into_iter().collect() - } -} - -/// Run a command in a login shell (tries zsh then bash on Unix, Git Bash on Windows). -/// Returns trimmed stdout if the command succeeds with non-empty output. -fn run_in_login_shell(args: &[&str]) -> Option { - for shell in login_shell_candidates() { - let mut cmd = Command::new(&shell); - cmd.args(args); - crate::util::configure_no_window(&mut cmd); - let Ok(output) = cmd.output() else { - continue; - }; - if !output.status.success() { - continue; - } - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !stdout.is_empty() { - return Some(stdout); - } - } - None -} - -fn find_via_login_shell(command: &str) -> Option { - let stdout = run_in_login_shell(&["-l", "-c", r#"command -v -- "$1""#, "_", command])?; - let resolved = stdout.lines().rfind(|line| !line.trim().is_empty())?; - let path = PathBuf::from(resolved.trim()); - (path.is_absolute() && is_executable_file(&path)).then_some(path) -} - -/// Three-state backing store for the login-shell PATH cache. -#[derive(Clone)] -enum LoginShellPath { - /// Cache has never been populated; the next call will spawn a login shell. - Uninit, - /// A login shell was invoked; the inner value is the PATH it returned - /// (`None` when the shell produced no output). - Probed(Option), -} - -fn path_cache() -> &'static std::sync::Mutex { - use std::sync::{Mutex, OnceLock}; - static CACHE: OnceLock> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(LoginShellPath::Uninit)) -} - -fn fetch_login_shell_path_inner() -> Option { - // On Windows, Git Bash's `echo $PATH` returns POSIX colon-delimited paths - // (`/mingw64/bin:/c/Users/...`) which poison native Windows children that - // split on `;`. login_shell_path() feeds agent_models, runtime, and - // cli_probe — all native processes. Return None so they inherit the real - // Windows PATH instead. - #[cfg(windows)] - { - return None; - } - - #[cfg(not(windows))] - { - let stdout = run_in_login_shell(&["-l", "-c", "echo $PATH"])?; - let last_line = stdout.lines().rfind(|l| !l.trim().is_empty())?; - Some(last_line.trim().to_string()) - } -} - -/// Return the user's full PATH from a login shell. -/// -/// The result is cached after the first call. Call [`refresh_login_shell_path`] -/// to invalidate the cache so the next call re-fetches — e.g. after the user -/// installs Node.js mid-session and clicks Retry. -/// -/// The lock is never held while the login shell spawns: we check for a cached -/// value, release the lock, run the shell, then re-lock to write. Two concurrent -/// callers may both run the shell (last-writer-wins is fine — both produce the -/// same result), but neither blocks a concurrent agent spawn on the Mutex. -pub fn login_shell_path() -> Option { - // Fast path: return cached result without spawning a shell. - { - let guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); - if let LoginShellPath::Probed(ref result) = *guard { - return result.clone(); - } - } - - // Slow path: spawn shell outside any lock. - let result = fetch_login_shell_path_inner(); - - // Write back; last-writer-wins is safe here. - { - let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); - *guard = LoginShellPath::Probed(result.clone()); - } - - result -} - -/// Invalidate the login-shell PATH cache so the next [`login_shell_path`] call -/// re-fetches from a fresh login shell. -/// -/// Called before every install/retry operation and on Doctor Re-run so a -/// newly-installed tool becomes visible without restarting the app. -pub(crate) fn refresh_login_shell_path() { - let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); - *guard = LoginShellPath::Uninit; -} - +/// Test-only counter for login-shell spawn attempts (see submodule). #[cfg(test)] -fn is_login_shell_path_uninit() -> bool { - matches!( - *path_cache().lock().unwrap_or_else(|e| e.into_inner()), - LoginShellPath::Uninit - ) -} - -/// Return `true` when `tag` is a safe nvm alias/version tag that can be joined -/// onto a `PathBuf` without escaping the nvm root. -/// -/// nvm uses tags like `v22.1.0` or `lts/hydrogen`. We allow ASCII alphanumeric -/// plus `. - / _` and require that no path component is `..` and that the tag -/// does not start with `/` (which would replace the base in `PathBuf::join`). -fn is_safe_nvm_tag(tag: &str) -> bool { - if tag.is_empty() { - return false; - } - // An absolute path in the alias file would let PathBuf::join silently - // replace the nvm root with an attacker-controlled path. - if tag.starts_with('/') { - return false; - } - // Reject any .. component to prevent upward traversal. - for component in tag.split('/') { - if component == ".." { - return false; - } - } - // Allow only the characters nvm uses in real tag names. - tag.chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '/' | '_')) -} - -/// Locate the `bin` directory for nvm's default Node.js version. -/// -/// Reads `~/.nvm/alias/default`; resolves at most one alias hop to handle -/// nvm alias chains; falls back to the highest-semver directory under -/// `~/.nvm/versions/node/`. Returns the `bin` subdirectory only when it exists. -/// -/// Cheap: at most two file reads or one `read_dir`. Never cached — computed -/// fresh per call so a mid-session `nvm install` is visible at the next spawn. -pub fn find_nvm_default_bin(home: &Path) -> Option { - let nvm_root = home.join(".nvm"); - let versions_root = nvm_root.join("versions").join("node"); - - // 1. Try alias/default, with at most one hop. - let default_alias = nvm_root.join("alias").join("default"); - if let Ok(content) = std::fs::read_to_string(&default_alias) { - let tag = content.trim().to_string(); - if is_safe_nvm_tag(&tag) { - let candidate = versions_root.join(&tag).join("bin"); - if candidate.is_dir() { - return Some(candidate); - } - // One alias hop: ~/.nvm/alias/ - let hop_file = nvm_root.join("alias").join(&tag); - if let Ok(hop_content) = std::fs::read_to_string(&hop_file) { - let hop_tag = hop_content.trim().to_string(); - if is_safe_nvm_tag(&hop_tag) { - let hop_candidate = versions_root.join(&hop_tag).join("bin"); - if hop_candidate.is_dir() { - return Some(hop_candidate); - } - } - } - } - } - - // 2. Fall back to highest-semver directory under ~/.nvm/versions/node/. - let entries = std::fs::read_dir(&versions_root).ok()?; - let best = entries - .filter_map(|e| e.ok()) - .filter_map(|e| { - let name = e.file_name(); - let s = name.to_string_lossy().into_owned(); - parse_semver_tag(&s).map(|v| (v, s)) - }) - .max_by(|(a, _), (b, _)| a.cmp(b)); - - let (_, tag) = best?; - let bin = versions_root.join(&tag).join("bin"); - bin.is_dir().then_some(bin) -} - -/// Parse a `vMAJ.MIN.PATCH` (or `vMAJ.MIN.PATCH-extra`) tag into a numeric -/// triple for semver comparison. -fn parse_semver_tag(s: &str) -> Option<(u64, u64, u64)> { - let s = s.strip_prefix('v')?; - let mut parts = s.splitn(3, '.'); - let major = parts.next()?.parse::().ok()?; - let minor = parts.next()?.parse::().ok()?; - let patch_str = parts.next()?; - let patch = patch_str.split('-').next()?.parse::().ok()?; - Some((major, minor, patch)) -} +#[path = "discovery/login_shell_spawn_probe.rs"] +pub(crate) mod login_shell_spawn_probe; pub(crate) fn find_command(command: &str) -> Option { resolve_command(command) @@ -1295,27 +1116,39 @@ struct PartialEntry { entry: AcpRuntimeCatalogEntry, } -fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntry { +fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime, force: bool) -> PartialEntry { + // Cheap path is cache-only (no login-shell spawn); forced path resolves live. + let resolve = if force { + resolve_command + } else { + resolve_command_cached + }; let adapter_result = runtime .commands .iter() - .find_map(|command| find_command(command).map(|path| (*command, path))); + .find_map(|command| resolve(command).map(|path| (*command, path))); let underlying_cli_found = runtime .underlying_cli - .map(|cli| find_command(cli).is_some()) + .map(|cli| resolve(cli).is_some()) .unwrap_or(false); let (mut availability, command, binary_path) = classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); - // For codex-acp: when the adapter resolves as Available, probe its full - // version. An adapter below MIN_CODEX_ACP_VERSION is treated as outdated. + // For codex-acp: when the adapter resolves as Available, determine its full + // version. A forced discovery probes the binary (spawns a subprocess); the + // cheap default path reuses the last cached availability so it stays + // process-free. An adapter below MIN_CODEX_ACP_VERSION is treated as outdated. if runtime.id == "codex" && availability == AcpAvailabilityStatus::Available && command.as_deref() == Some("codex-acp") { - if let Some(path_str) = &binary_path { - availability = codex_adapter_availability(&PathBuf::from(path_str)); + if force { + if let Some(path_str) = &binary_path { + availability = codex_adapter_availability(&PathBuf::from(path_str)); + } + } else if let Some(cached) = adapter_availability_cached() { + availability = cached; } } @@ -1328,7 +1161,7 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr let underlying_cli_path = runtime .underlying_cli - .and_then(find_command) + .and_then(resolve) .map(|p| p.display().to_string()); let default_args = command @@ -1373,8 +1206,8 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::NotInstalled ) && runtime_needs_npm(runtime) && buzz_managed_node_bin_dir().is_none() - && resolve_command("npm").is_none() - && resolve_command("node").is_none(); + && resolve("npm").is_none() + && resolve("node").is_none(); PartialEntry { runtime, @@ -1415,7 +1248,9 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr /// resolves, so it should not pay the cost of authenticating every catalog entry. pub(crate) fn discover_acp_runtime_availability(runtime_id: &str) -> Option { known_acp_runtime_exact(runtime_id) - .map(discover_acp_runtime_phase1) + // Post-install verification wants fresh filesystem/version state, so + // probe rather than trust the cheap-path cache. + .map(|runtime| discover_acp_runtime_phase1(runtime, true)) .map(|partial| partial.entry.availability) } @@ -1438,47 +1273,24 @@ pub(crate) fn discover_acp_runtime_availability(runtime_id: &str) -> Option, + force: bool, ) -> Vec { + // Cheap path is cache-only (no login-shell spawn); forced path resolves live. + let resolve = if force { + resolve_command + } else { + resolve_command_cached + }; + // Phase 1: build all builtin entries (fast — no probes yet). let mut partials: Vec = KNOWN_ACP_RUNTIMES .iter() - .map(discover_acp_runtime_phase1) + .map(|runtime| discover_acp_runtime_phase1(runtime, force)) .collect(); - // Phase 2: run auth probes in parallel for entries that need them. - // Spawn one thread per probeable entry; total cost = max(probe latency). - let probe_handles: Vec<(usize, std::thread::JoinHandle)> = partials - .iter() - .enumerate() - .filter_map(|(idx, partial)| { - if partial.entry.availability != AcpAvailabilityStatus::Available { - return None; - } - let probe_args = partial.runtime.auth_probe_args?; - // Need the resolved binary path for the CLI (e.g. the actual `claude` binary). - let binary_path = resolve_command(probe_args[0])?; - let probe_args_owned: Vec = probe_args.iter().map(|s| s.to_string()).collect(); - - let handle = std::thread::spawn(move || { - let refs: Vec<&str> = probe_args_owned.iter().map(String::as_str).collect(); - probe_auth_status(&binary_path, &refs) - }); - Some((idx, handle)) - }) - .collect(); - - // Collect probe results and patch entries. - for (idx, handle) in probe_handles { - let status = handle.join().unwrap_or(AuthStatus::Unknown); - let partial = &mut partials[idx]; - partial.entry.login_hint = - if matches!(status, AuthStatus::LoggedIn | AuthStatus::NotApplicable) { - None - } else { - partial.runtime.login_hint.map(str::to_string) - }; - partial.entry.auth_status = status; - } + // Phase 2: resolve each available runtime's auth status (forced discovery + // spawns parallel CLI probes and warms the cache; the cheap path reuses it). + auth_status_cache::resolve_auth_statuses(&mut partials, force); // Fill NotApplicable / Unknown for non-probed entries. for partial in &mut partials { @@ -1508,7 +1320,7 @@ pub fn discover_acp_runtimes_from( } seen_ids.insert(def.id.to_string()); - entries.push(preset_catalog_entry(def, find_command)); + entries.push(preset_catalog_entry(def, resolve)); } // Phase 3: load and append custom harness definitions. @@ -1523,8 +1335,8 @@ pub fn discover_acp_runtimes_from( continue; } - // Availability: command on PATH → Available, else NotInstalled. - let (availability, command, binary_path) = match find_command(&def.command) { + // Availability: command resolves → Available, else NotInstalled. + let (availability, command, binary_path) = match resolve(&def.command) { Some(path) => ( AcpAvailabilityStatus::Available, Some(def.command.clone()), diff --git a/desktop/src-tauri/src/managed_agents/discovery/auth_status_cache.rs b/desktop/src-tauri/src/managed_agents/discovery/auth_status_cache.rs new file mode 100644 index 0000000000..cae0d7e2c9 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/auth_status_cache.rs @@ -0,0 +1,105 @@ +//! Auth-status cache for cheap ACP runtime discovery. +//! +//! A forced discovery (`discover_acp_providers(force: true)`) spawns one CLI +//! auth probe per available runtime — the expensive pipeline. The cheap default +//! discovery must not pay that cost, so it reuses the last known auth statuses +//! from this cache instead of probing. The cache is keyed by runtime id, warmed +//! by the forced probe phase, and cleared by `clear_resolve_cache` (which a +//! forced discovery calls before re-probing). + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +use crate::managed_agents::AuthStatus; + +fn cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub(super) fn clear() { + if let Ok(mut guard) = cache().lock() { + guard.clear(); + } +} + +pub(super) fn store(runtime_id: &str, status: &AuthStatus) { + if let Ok(mut guard) = cache().lock() { + guard.insert(runtime_id.to_string(), status.clone()); + } +} + +/// Last known auth status for `runtime_id`, or `AuthStatus::Unknown` when no +/// forced discovery has probed it yet. Never spawns a process. +pub(super) fn get(runtime_id: &str) -> AuthStatus { + cache() + .lock() + .ok() + .and_then(|g| g.get(runtime_id).cloned()) + .unwrap_or(AuthStatus::Unknown) +} + +#[cfg(test)] +pub(crate) fn len() -> usize { + cache().lock().map(|g| g.len()).unwrap_or(0) +} + +/// Resolve the auth status of every available, probeable runtime in `partials`, +/// patching each entry's `auth_status` + `login_hint` in place. +/// +/// Forced discovery spawns one CLI auth probe per available runtime (in +/// parallel; total cost = max(probe latency)) and warms this cache. The cheap +/// default path spawns nothing — it reuses the last cached status, falling back +/// to `Unknown` for a runtime never probed this session. +pub(super) fn resolve_auth_statuses(partials: &mut [super::PartialEntry], force: bool) { + use crate::managed_agents::AcpAvailabilityStatus; + + if force { + let probe_handles: Vec<(usize, std::thread::JoinHandle)> = partials + .iter() + .enumerate() + .filter_map(|(idx, partial)| { + if partial.entry.availability != AcpAvailabilityStatus::Available { + return None; + } + let probe_args = partial.runtime.auth_probe_args?; + // Need the resolved binary path for the CLI (e.g. the actual `claude` binary). + let binary_path = super::resolve_command(probe_args[0])?; + let probe_args_owned: Vec = + probe_args.iter().map(|s| s.to_string()).collect(); + + let handle = std::thread::spawn(move || { + let refs: Vec<&str> = probe_args_owned.iter().map(String::as_str).collect(); + super::probe_auth_status(&binary_path, &refs) + }); + Some((idx, handle)) + }) + .collect(); + + for (idx, handle) in probe_handles { + let status = handle.join().unwrap_or(AuthStatus::Unknown); + store(&partials[idx].entry.id, &status); + patch_entry(&mut partials[idx], status); + } + } else { + for partial in partials.iter_mut() { + if partial.entry.availability != AcpAvailabilityStatus::Available + || partial.runtime.auth_probe_args.is_none() + { + continue; + } + let status = get(&partial.entry.id); + patch_entry(partial, status); + } + } +} + +fn patch_entry(partial: &mut super::PartialEntry, status: AuthStatus) { + partial.entry.login_hint = if matches!(status, AuthStatus::LoggedIn | AuthStatus::NotApplicable) + { + None + } else { + partial.runtime.login_hint.map(str::to_string) + }; + partial.entry.auth_status = status; +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/login_shell.rs b/desktop/src-tauri/src/managed_agents/discovery/login_shell.rs new file mode 100644 index 0000000000..d8f8e60354 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/login_shell.rs @@ -0,0 +1,236 @@ +//! Login-shell PATH discovery and nvm fallback. +//! +//! Extracted verbatim from `discovery.rs` to keep that file under the +//! file-size ratchet. Covers login-shell candidate selection, the cached +//! login-shell PATH probe, and nvm default-bin resolution. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use super::is_executable_file; + +/// Test-only spawn counter lives beside `discovery.rs`; import it here so the +/// spawn-record call site stays byte-identical to the pre-extraction source. +#[cfg(test)] +use super::login_shell_spawn_probe; + +/// Collect login shell candidates for the current platform. +/// +/// On Unix: `/bin/zsh`, `/bin/bash` (the historical defaults). +/// On Windows: Git Bash via `resolve_bash_path` — skips `BUZZ_SHELL` because +/// login-shell callers use bash-only `-l -c` syntax. +pub(crate) fn login_shell_candidates() -> Vec { + #[cfg(not(windows))] + { + vec![PathBuf::from("/bin/zsh"), PathBuf::from("/bin/bash")] + } + #[cfg(windows)] + { + super::super::git_bash::resolve_bash_path() + .into_iter() + .collect() + } +} + +/// Run a command in a login shell (tries zsh then bash on Unix, Git Bash on Windows). +/// Returns trimmed stdout if the command succeeds with non-empty output. +fn run_in_login_shell(args: &[&str]) -> Option { + #[cfg(test)] + login_shell_spawn_probe::record(); + for shell in login_shell_candidates() { + let mut cmd = Command::new(&shell); + cmd.args(args); + crate::util::configure_no_window(&mut cmd); + let Ok(output) = cmd.output() else { + continue; + }; + if !output.status.success() { + continue; + } + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !stdout.is_empty() { + return Some(stdout); + } + } + None +} + +pub(crate) fn find_via_login_shell(command: &str) -> Option { + let stdout = run_in_login_shell(&["-l", "-c", r#"command -v -- "$1""#, "_", command])?; + let resolved = stdout.lines().rfind(|line| !line.trim().is_empty())?; + let path = PathBuf::from(resolved.trim()); + (path.is_absolute() && is_executable_file(&path)).then_some(path) +} + +/// Three-state backing store for the login-shell PATH cache. +#[derive(Clone)] +enum LoginShellPath { + /// Cache has never been populated; the next call will spawn a login shell. + Uninit, + /// A login shell was invoked; the inner value is the PATH it returned + /// (`None` when the shell produced no output). + Probed(Option), +} + +fn path_cache() -> &'static std::sync::Mutex { + use std::sync::{Mutex, OnceLock}; + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(LoginShellPath::Uninit)) +} + +fn fetch_login_shell_path_inner() -> Option { + // On Windows, Git Bash's `echo $PATH` returns POSIX colon-delimited paths + // (`/mingw64/bin:/c/Users/...`) which poison native Windows children that + // split on `;`. login_shell_path() feeds agent_models, runtime, and + // cli_probe — all native processes. Return None so they inherit the real + // Windows PATH instead. + #[cfg(windows)] + { + return None; + } + + #[cfg(not(windows))] + { + let stdout = run_in_login_shell(&["-l", "-c", "echo $PATH"])?; + let last_line = stdout.lines().rfind(|l| !l.trim().is_empty())?; + Some(last_line.trim().to_string()) + } +} + +/// Return the user's full PATH from a login shell. +/// +/// The result is cached after the first call. Call [`refresh_login_shell_path`] +/// to invalidate the cache so the next call re-fetches — e.g. after the user +/// installs Node.js mid-session and clicks Retry. +/// +/// The lock is never held while the login shell spawns: we check for a cached +/// value, release the lock, run the shell, then re-lock to write. Two concurrent +/// callers may both run the shell (last-writer-wins is fine — both produce the +/// same result), but neither blocks a concurrent agent spawn on the Mutex. +pub fn login_shell_path() -> Option { + // Fast path: return cached result without spawning a shell. + { + let guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); + if let LoginShellPath::Probed(ref result) = *guard { + return result.clone(); + } + } + + // Slow path: spawn shell outside any lock. + let result = fetch_login_shell_path_inner(); + + // Write back; last-writer-wins is safe here. + { + let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); + *guard = LoginShellPath::Probed(result.clone()); + } + + result +} + +/// Invalidate the login-shell PATH cache so the next [`login_shell_path`] call +/// re-fetches from a fresh login shell. +/// +/// Called before every install/retry operation and on Doctor Re-run so a +/// newly-installed tool becomes visible without restarting the app. +pub(crate) fn refresh_login_shell_path() { + let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); + *guard = LoginShellPath::Uninit; +} + +#[cfg(test)] +pub(crate) fn is_login_shell_path_uninit() -> bool { + matches!( + *path_cache().lock().unwrap_or_else(|e| e.into_inner()), + LoginShellPath::Uninit + ) +} + +/// Return `true` when `tag` is a safe nvm alias/version tag that can be joined +/// onto a `PathBuf` without escaping the nvm root. +/// +/// nvm uses tags like `v22.1.0` or `lts/hydrogen`. We allow ASCII alphanumeric +/// plus `. - / _` and require that no path component is `..` and that the tag +/// does not start with `/` (which would replace the base in `PathBuf::join`). +pub(crate) fn is_safe_nvm_tag(tag: &str) -> bool { + if tag.is_empty() { + return false; + } + // An absolute path in the alias file would let PathBuf::join silently + // replace the nvm root with an attacker-controlled path. + if tag.starts_with('/') { + return false; + } + // Reject any .. component to prevent upward traversal. + for component in tag.split('/') { + if component == ".." { + return false; + } + } + // Allow only the characters nvm uses in real tag names. + tag.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '/' | '_')) +} + +/// Locate the `bin` directory for nvm's default Node.js version. +/// +/// Reads `~/.nvm/alias/default`; resolves at most one alias hop to handle +/// nvm alias chains; falls back to the highest-semver directory under +/// `~/.nvm/versions/node/`. Returns the `bin` subdirectory only when it exists. +/// +/// Cheap: at most two file reads or one `read_dir`. Never cached — computed +/// fresh per call so a mid-session `nvm install` is visible at the next spawn. +pub fn find_nvm_default_bin(home: &Path) -> Option { + let nvm_root = home.join(".nvm"); + let versions_root = nvm_root.join("versions").join("node"); + + // 1. Try alias/default, with at most one hop. + let default_alias = nvm_root.join("alias").join("default"); + if let Ok(content) = std::fs::read_to_string(&default_alias) { + let tag = content.trim().to_string(); + if is_safe_nvm_tag(&tag) { + let candidate = versions_root.join(&tag).join("bin"); + if candidate.is_dir() { + return Some(candidate); + } + // One alias hop: ~/.nvm/alias/ + let hop_file = nvm_root.join("alias").join(&tag); + if let Ok(hop_content) = std::fs::read_to_string(&hop_file) { + let hop_tag = hop_content.trim().to_string(); + if is_safe_nvm_tag(&hop_tag) { + let hop_candidate = versions_root.join(&hop_tag).join("bin"); + if hop_candidate.is_dir() { + return Some(hop_candidate); + } + } + } + } + } + + // 2. Fall back to highest-semver directory under ~/.nvm/versions/node/. + let entries = std::fs::read_dir(&versions_root).ok()?; + let best = entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + let name = e.file_name(); + let s = name.to_string_lossy().into_owned(); + parse_semver_tag(&s).map(|v| (v, s)) + }) + .max_by(|(a, _), (b, _)| a.cmp(b)); + + let (_, tag) = best?; + let bin = versions_root.join(&tag).join("bin"); + bin.is_dir().then_some(bin) +} + +/// Parse a `vMAJ.MIN.PATCH` (or `vMAJ.MIN.PATCH-extra`) tag into a numeric +/// triple for semver comparison. +pub(crate) fn parse_semver_tag(s: &str) -> Option<(u64, u64, u64)> { + let s = s.strip_prefix('v')?; + let mut parts = s.splitn(3, '.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let patch_str = parts.next()?; + let patch = patch_str.split('-').next()?.parse::().ok()?; + Some((major, minor, patch)) +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/login_shell_spawn_probe.rs b/desktop/src-tauri/src/managed_agents/discovery/login_shell_spawn_probe.rs new file mode 100644 index 0000000000..a716dee9f5 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/login_shell_spawn_probe.rs @@ -0,0 +1,21 @@ +//! Test-only counter for login-shell spawn attempts. +//! +//! `run_in_login_shell` is the single subprocess-spawning step on the +//! absent-command resolution path, so counting its calls proves whether a +//! cheap discovery re-spawns after a negative resolution was cached. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +static COUNT: AtomicUsize = AtomicUsize::new(0); + +pub(crate) fn record() { + COUNT.fetch_add(1, Ordering::SeqCst); +} + +pub(crate) fn reset() { + COUNT.store(0, Ordering::SeqCst); +} + +pub(crate) fn count() -> usize { + COUNT.load(Ordering::SeqCst) +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index d86e5f33f0..fd85309451 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -336,7 +336,7 @@ mod tests { let _path_guard = crate::managed_agents::lock_path_mutex(); let _registry_guard = registry_test_lock(); - let entry = super::super::discover_acp_runtimes_from(None) + let entry = super::super::discover_acp_runtimes_from(None, true) .into_iter() .find(|entry| entry.id == "devin") .expect("Devin preset should appear in the runtime catalog"); diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index f7e233fbe9..2d1db69293 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -4,11 +4,10 @@ use super::overrides::{divergent_agent_command_override, update_time_agent_comma use super::{ apply_agent_command_update, classify_runtime, codex_adapter_availability, codex_adapter_is_outdated, create_time_agent_command_override, default_agent_command, - effective_agent_command, find_nvm_default_bin, find_via_login_shell, - is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args, - parse_semver_tag, probe_codex_acp_version, record_agent_command, refresh_login_shell_path, - try_record_agent_command, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, - GOOSE_AVATAR_URL, + effective_agent_command, find_nvm_default_bin, is_login_shell_path_uninit, is_safe_nvm_tag, + managed_agent_avatar_url, normalize_agent_args, parse_semver_tag, probe_codex_acp_version, + record_agent_command, refresh_login_shell_path, try_record_agent_command, + BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; @@ -94,24 +93,6 @@ fn normalizes_buzz_agent_args_to_empty() { ); } -#[test] -fn login_shell_lookup_treats_command_as_data() { - let marker = - std::env::temp_dir().join(format!("buzz-discovery-marker-{}", uuid::Uuid::new_v4())); - let payload = format!("doesnotexist; touch {} #", marker.display()); - - let resolved = find_via_login_shell(&payload); - - assert!( - resolved.is_none(), - "payload should not resolve to a command" - ); - assert!( - !marker.exists(), - "shell lookup must not execute injected commands" - ); -} - #[cfg(unix)] #[test] fn explicit_path_resolution_ignores_non_executable_files() { @@ -668,8 +649,8 @@ fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime() { // ── probe_codex_acp_version ─────────────────────────────────────────────────── +mod forced_discovery; mod managed_path_resolution; - #[cfg(unix)] #[test] fn probe_codex_acp_version_parses_full_semver_output() { @@ -1685,7 +1666,7 @@ fn custom_catalog_entry_carries_definition_env_for_edit_roundtrip() { ) .unwrap(); - let entries = discover_acp_runtimes_from(Some(dir.path())); + let entries = discover_acp_runtimes_from(Some(dir.path()), true); let entry = entries .iter() .find(|e| e.id == "env-harness") @@ -1715,7 +1696,7 @@ fn builtin_catalog_entry_has_empty_definition_env() { // publishes to the global registry. let _path_guard = crate::managed_agents::lock_path_mutex(); let _lock = registry_test_lock(); - let entries = discover_acp_runtimes_from(None); + let entries = discover_acp_runtimes_from(None, true); // Find any builtin entry (e.g. "goose" or "claude"). let builtin = entries .iter() @@ -1796,7 +1777,7 @@ fn discovery_publish_path_survives_mid_flight_save() { assert!(lookup_loaded_harness_by_id("mid-flight-save").is_some()); })); - let _entries = discover_acp_runtimes_from(Some(dir.path())); + let _entries = discover_acp_runtimes_from(Some(dir.path()), true); assert!( lookup_loaded_harness_by_id("mid-flight-save").is_some(), @@ -1829,7 +1810,7 @@ fn discovery_publish_path_drops_mid_flight_delete() { assert!(lookup_loaded_harness_by_id("mid-flight-delete").is_none()); })); - let _entries = discover_acp_runtimes_from(Some(dir.path())); + let _entries = discover_acp_runtimes_from(Some(dir.path()), true); assert!( lookup_loaded_harness_by_id("mid-flight-delete").is_none(), diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/forced_discovery.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/forced_discovery.rs new file mode 100644 index 0000000000..cfbad365e3 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/forced_discovery.rs @@ -0,0 +1,163 @@ +// ── Cheap vs. forced discovery: the auth-probe split ──────────────────────── +// +// `discover_acp_providers(force: true)` spawns one CLI auth probe per available +// runtime; the cheap default path must reuse the last cached status and spawn +// nothing. These tests pin that split through the real `discover_acp_runtimes_from` +// pipeline with a fake `claude` CLI that records every invocation to a sentinel. + +/// Build a fake `claude` runtime on a fresh PATH: the adapter (`claude-agent-acp`) +/// and the CLI (`claude`). The CLI appends a line to `probe_log` each time it +/// runs and exits 0 (→ `LoggedIn`), so the log's existence proves whether the +/// auth probe was spawned. +#[cfg(unix)] +#[test] +fn forced_discovery_probes_auth_but_cheap_discovery_reuses_cached_status() { + use crate::managed_agents::custom_harnesses::registry_test_lock; + use crate::managed_agents::discovery::{clear_resolve_cache, discover_acp_runtimes_from}; + use crate::managed_agents::{AcpAvailabilityStatus, AuthStatus}; + use std::os::unix::fs::PermissionsExt; + + let _path_guard = crate::managed_agents::lock_path_mutex(); + let _registry_guard = registry_test_lock(); + + let dir = tempfile::tempdir().expect("tempdir"); + let probe_log = dir.path().join("claude-probe.log"); + + for name in ["claude-agent-acp", "claude"] { + let bin = dir.path().join(name); + // The adapter is never executed; only `claude` logs + exits 0. + let script = format!( + "#!/bin/sh\necho ran >> \"{}\"\nexit 0\n", + probe_log.display() + ); + std::fs::write(&bin, script).expect("write fake bin"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + } + + // Start from a clean resolve + auth cache, and a PATH that only sees our fakes. + clear_resolve_cache(); + let old_path = std::env::var_os("PATH").unwrap_or_default(); + let mut new_path = vec![dir.path().to_path_buf()]; + new_path.extend(std::env::split_paths(&old_path)); + std::env::set_var("PATH", std::env::join_paths(&new_path).expect("join PATH")); + + let result = std::panic::catch_unwind(|| { + // ── Forced: probes run, status is LoggedIn, cache is warmed. ────────── + let forced = discover_acp_runtimes_from(None, true); + let claude = forced + .iter() + .find(|e| e.id == "claude") + .expect("claude entry present"); + assert_eq!(claude.availability, AcpAvailabilityStatus::Available); + assert_eq!(claude.auth_status, AuthStatus::LoggedIn); + assert!( + probe_log.exists(), + "forced discovery must spawn the auth probe" + ); + assert!( + super::super::auth_status_cache::len() > 0, + "forced discovery must warm the auth-status cache" + ); + + // ── Cheap: no probe spawned, status reused from cache. ──────────────── + std::fs::remove_file(&probe_log).expect("clear probe log"); + let cheap = discover_acp_runtimes_from(None, false); + let claude = cheap + .iter() + .find(|e| e.id == "claude") + .expect("claude entry present"); + assert_eq!( + claude.availability, + AcpAvailabilityStatus::Available, + "cheap path keeps availability (resolved from cache)" + ); + assert_eq!( + claude.auth_status, + AuthStatus::LoggedIn, + "cheap path must reuse the cached auth status" + ); + assert!( + !probe_log.exists(), + "cheap discovery must not spawn any auth probe" + ); + }); + + // Restore global state before propagating any panic. + std::env::set_var("PATH", &old_path); + clear_resolve_cache(); + if let Err(e) = result { + std::panic::resume_unwind(e); + } +} + +/// Before any forced probe warms the resolve cache, the cheap path resolves +/// nothing live — it must not resolve a present-but-uncached binary by spawning +/// a login shell to discover it. This is the flip side of the zero-spawn +/// contract: cache-only resolution cannot see a binary the forced path has not +/// yet cached. The forced path (exercised on every surface mount) resolves it +/// and warms the cache; a subsequent cheap call then sees it Available (covered +/// by `forced_discovery_probes_auth_but_cheap_discovery_reuses_cached_status`). +/// +/// The assertion is scoped to what holds on any machine: the fake PATH-only +/// `claude` CLI must not be resolved by the cheap path (availability is never +/// `Available`, auth stays `Unknown`) and no login shell is spawned. It does +/// not pin the exact `NotInstalled` vs `CliMissing` variant, because a real +/// Buzz-managed `claude-agent-acp` shim on the host resolves via a filesystem +/// stat (production-correct, never a spawn) and yields `CliMissing` — a genuine +/// environment difference, not a regression. +#[cfg(unix)] +#[test] +fn cheap_discovery_reports_absent_before_any_forced_probe() { + use crate::managed_agents::custom_harnesses::registry_test_lock; + use crate::managed_agents::discovery::{ + clear_resolve_cache, discover_acp_runtimes_from, login_shell_spawn_probe, + }; + use crate::managed_agents::{AcpAvailabilityStatus, AuthStatus}; + use std::os::unix::fs::PermissionsExt; + + let _path_guard = crate::managed_agents::lock_path_mutex(); + let _registry_guard = registry_test_lock(); + + let dir = tempfile::tempdir().expect("tempdir"); + for name in ["claude-agent-acp", "claude"] { + let bin = dir.path().join(name); + std::fs::write(&bin, "#!/bin/sh\nexit 0\n").expect("write fake bin"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + } + + clear_resolve_cache(); // also clears the auth-status cache + login_shell_spawn_probe::reset(); + let old_path = std::env::var_os("PATH").unwrap_or_default(); + let mut new_path = vec![dir.path().to_path_buf()]; + new_path.extend(std::env::split_paths(&old_path)); + std::env::set_var("PATH", std::env::join_paths(&new_path).expect("join PATH")); + + let result = std::panic::catch_unwind(|| { + let cheap = discover_acp_runtimes_from(None, false); + let claude = cheap + .iter() + .find(|e| e.id == "claude") + .expect("claude entry present"); + assert_ne!( + claude.availability, + AcpAvailabilityStatus::Available, + "cache-only cheap discovery must not resolve the PATH-only claude CLI live" + ); + assert_eq!( + claude.auth_status, + AuthStatus::Unknown, + "an unresolved runtime with no cached status stays Unknown" + ); + assert_eq!( + login_shell_spawn_probe::count(), + 0, + "cheap discovery must not spawn a login shell to resolve the PATH-only CLI" + ); + }); + + std::env::set_var("PATH", &old_path); + clear_resolve_cache(); + if let Err(e) = result { + std::panic::resume_unwind(e); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs index 0795bb2345..5369b6321b 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs @@ -1,5 +1,28 @@ use crate::managed_agents::discovery::{clear_resolve_cache, resolve_command}; +/// A login-shell command lookup must treat its argument as pure data — a +/// payload containing shell metacharacters must never execute. +#[test] +fn login_shell_lookup_treats_command_as_data() { + use super::super::find_via_login_shell; + + let _guard = crate::managed_agents::lock_path_mutex(); + let marker = + std::env::temp_dir().join(format!("buzz-discovery-marker-{}", uuid::Uuid::new_v4())); + let payload = format!("doesnotexist; touch {} #", marker.display()); + + let resolved = find_via_login_shell(&payload); + + assert!( + resolved.is_none(), + "payload should not resolve to a command" + ); + assert!( + !marker.exists(), + "shell lookup must not execute injected commands" + ); +} + /// The legacy Goose Windows installer wrote `%USERPROFILE%\goose\goose.exe`, /// a directory on no standard PATH. `resolve_command_uncached` finds binaries /// outside PATH only by scanning `common_binary_paths()`, so that directory @@ -88,3 +111,79 @@ fn resolve_command_prefers_buzz_managed_npm_shim_over_path() { "Buzz-managed npm shim must win over PATH/global shims" ); } + +/// The cheap discovery path must never spawn a login shell — not even on a +/// cold cache. +/// +/// `force: false` resolves commands from cache only (`resolve_command_cached`): +/// on a resolve-cache miss it reports the command absent instead of falling +/// through to `resolve_command_uncached` → `find_via_login_shell`, which spawns +/// zsh/bash. That spawn on the channel-switch/composer hot path is the exact +/// freeze source the cheap path exists to avoid, so a cold cheap call must +/// spawn zero login shells. The forced path remains the sole prober: the same +/// absent-command fixture spawns at least once under `force: true`, proving the +/// cheap-path zero is real and not a fixture that never reaches the probe. +#[cfg(unix)] +#[test] +fn cheap_discovery_never_spawns_login_shell_even_when_cold() { + use crate::managed_agents::custom_harnesses::registry_test_lock; + use crate::managed_agents::discovery::{ + clear_resolve_cache, discover_acp_runtimes_from, login_shell_spawn_probe, + }; + use std::fs; + use tempfile::tempdir; + + // Serialize with every other test that spawns a login shell: the spawn + // counter and the PATH/login-shell caches are process-global. + let _path_guard = crate::managed_agents::lock_path_mutex(); + let _registry = registry_test_lock(); + + // A custom harness whose command cannot resolve anywhere, so the resolver + // reaches `find_via_login_shell` under the forced (live) path. + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("absent-harness.json"), + r#"{ + "id": "absent-harness", + "label": "Absent Harness", + "command": "buzz-absent-command-xyzzy", + "args": [] + }"#, + ) + .unwrap(); + + // Cold cache, cheap path: must spawn ZERO login shells (cache-only resolve + // reports the absent command missing without probing). + clear_resolve_cache(); + login_shell_spawn_probe::reset(); + let _ = discover_acp_runtimes_from(Some(dir.path()), false); + let cold_cheap = login_shell_spawn_probe::count(); + assert_eq!( + cold_cheap, 0, + "a cold cheap discovery must not spawn any login shell, got {cold_cheap}" + ); + + // Second cheap discovery, still cold (no forced probe populated the cache): + // still zero — cache-only resolution never probes. + login_shell_spawn_probe::reset(); + let _ = discover_acp_runtimes_from(Some(dir.path()), false); + let second_cheap = login_shell_spawn_probe::count(); + assert_eq!( + second_cheap, 0, + "a repeated cheap discovery must not spawn any login shell, got {second_cheap}" + ); + + // Forced path over the SAME absent fixture: resolves live and reaches + // `find_via_login_shell` at least once. Proves the cheap-path zero above is + // genuine — the fixture does drive the probe when live resolution runs — + // not a vacuous zero from a fixture that never reaches it. + clear_resolve_cache(); + login_shell_spawn_probe::reset(); + let _ = discover_acp_runtimes_from(Some(dir.path()), true); + let forced = login_shell_spawn_probe::count(); + clear_resolve_cache(); + assert!( + forced >= 1, + "the forced path must probe the absent command via login shell at least once, got {forced}" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/cli_tests.rs b/desktop/src-tauri/src/managed_agents/runtime/cli_tests.rs new file mode 100644 index 0000000000..2d4fee340a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/cli_tests.rs @@ -0,0 +1,38 @@ +//! Runtime CLI configuration regression tests kept beside the configured seam. + +use super::super::configure_runtime_cli; +use crate::managed_agents::known_acp_runtime; + +#[test] +fn claude_spawn_uses_the_probed_cli_executable() { + let _guard = crate::managed_agents::lock_path_mutex(); + let temp = tempfile::tempdir().expect("temp dir"); + let cli = temp + .path() + .join(format!("claude{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&cli, "").expect("write fake cli"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)) + .expect("make fake cli executable"); + } + let original_path = std::env::var_os("PATH"); + std::env::set_var("PATH", temp.path()); + // The resolver retains negative results across tests, so the fake CLI must + // invalidate both before configuration and after restoring PATH. + crate::managed_agents::clear_resolve_cache(); + + let mut command = std::process::Command::new("buzz-acp"); + configure_runtime_cli(&mut command, known_acp_runtime("claude-agent-acp")); + + if let Some(path) = original_path { + std::env::set_var("PATH", path); + } else { + std::env::remove_var("PATH"); + } + crate::managed_agents::clear_resolve_cache(); + assert!(command + .get_envs() + .any(|(key, value)| { key == "CLAUDE_CODE_EXECUTABLE" && value == Some(cli.as_os_str()) })); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index b54c0e7a05..8bedfe5320 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1,5 +1,8 @@ use crate::managed_agents::known_acp_runtime; +#[path = "cli_tests.rs"] +mod cli_tests; + // ── desktop binary name tests ─────────────────────────────────────────── #[test] @@ -582,36 +585,6 @@ fn name_matches_interpreter_rejects_node_prefix() { assert!(!super::name_matches_interpreter("node-gyp")); } -#[test] -fn claude_spawn_uses_the_probed_cli_executable() { - let _guard = crate::managed_agents::lock_path_mutex(); - let temp = tempfile::tempdir().expect("temp dir"); - let cli = temp - .path() - .join(format!("claude{}", std::env::consts::EXE_SUFFIX)); - std::fs::write(&cli, "").expect("write fake cli"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)) - .expect("make fake cli executable"); - } - let original_path = std::env::var_os("PATH"); - std::env::set_var("PATH", temp.path()); - - let mut command = std::process::Command::new("buzz-acp"); - super::configure_runtime_cli(&mut command, super::known_acp_runtime("claude-agent-acp")); - - if let Some(path) = original_path { - std::env::set_var("PATH", path); - } else { - std::env::remove_var("PATH"); - } - assert!(command - .get_envs() - .any(|(key, value)| { key == "CLAUDE_CODE_EXECUTABLE" && value == Some(cli.as_os_str()) })); -} - #[test] fn codex_spawn_does_not_set_a_claude_executable() { let mut command = std::process::Command::new("buzz-acp"); diff --git a/desktop/src/features/agents/acpRuntimesQuery.test.mjs b/desktop/src/features/agents/acpRuntimesQuery.test.mjs new file mode 100644 index 0000000000..c51dea05b8 --- /dev/null +++ b/desktop/src/features/agents/acpRuntimesQuery.test.mjs @@ -0,0 +1,491 @@ +/** + * Regression tests for the cheap/forced ACP runtime discovery split. + * + * Two IMPORTANT correctness contracts from the review of the split: + * + * (1) refreshAcpRuntimes() must never coalesce onto an in-flight *cheap* + * request. React Query's fetchQuery deduplicates on the shared query key, + * so a cheap fetch already running would otherwise satisfy the forced + * refresh with cached data and the forced { force: true } probe would + * never run. The fix runs the forced probe on a separate key, writes its + * result into the shared cache, then cancels the in-flight cheap query. + * This test holds a cheap request pending, fires refreshAcpRuntimes(), + * resolves the cheap request, and asserts a distinct { force: true } native + * call happened and the shared cache holds the forced result. + * + * (2) useAcpRuntimesQueryForced({ forceOnMount: false }) must consume shared + * state without mounting its own force effect. Onboarding mounts the hook + * once as the surface owner (forceOnMount default true) and once per row + * (forceOnMount false); entering the surface must cause exactly one forced + * native call before any user action. + * + * The Tauri IPC bridge is stubbed at globalThis.__TAURI_INTERNALS__.invoke so + * discoverAcpRuntimes() calls are intercepted by command name and the { force } + * payload is observed directly (same pattern as + * useLoadArchivedObserverEvents.test.mjs). + */ + +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; + +// ── Minimal DOM shim (subset used by other mounted-hook tests) ──────────────── + +function installDOMShim() { + if (globalThis.document) return; + + class MinimalEventTarget { + constructor() { + this._listeners = {}; + } + addEventListener(type, fn) { + this._listeners[type] ??= []; + this._listeners[type].push(fn); + } + removeEventListener(type, fn) { + this._listeners[type] = (this._listeners[type] ?? []).filter( + (f) => f !== fn, + ); + } + dispatchEvent(e) { + for (const fn of this._listeners[e.type] ?? []) fn(e); + return true; + } + } + + class MinimalNode extends MinimalEventTarget { + constructor(tagName) { + super(); + this.tagName = tagName; + this.children = []; + this.childNodes = []; + this.style = {}; + this.nodeType = 1; + this.parentNode = null; + } + get ownerDocument() { + return globalThis.document; + } + get firstChild() { + return this.children[0] ?? null; + } + get nextSibling() { + return null; + } + appendChild(child) { + this.children.push(child); + this.childNodes.push(child); + child.parentNode = this; + return child; + } + removeChild(child) { + this.children = this.children.filter((c) => c !== child); + this.childNodes = this.childNodes.filter((c) => c !== child); + return child; + } + insertBefore(newNode, refNode) { + if (!refNode) return this.appendChild(newNode); + const i = this.children.indexOf(refNode); + if (i < 0) return this.appendChild(newNode); + this.children.splice(i, 0, newNode); + this.childNodes.splice(i, 0, newNode); + newNode.parentNode = this; + return newNode; + } + contains(node) { + if (!node) return false; + return this === node || this.children.some((c) => c?.contains?.(node)); + } + } + + class MinimalDocument extends MinimalEventTarget { + constructor() { + super(); + this.nodeType = 9; + } + createElement(tagName) { + return new MinimalNode(tagName); + } + createTextNode(value) { + const n = new MinimalNode("#text"); + n.nodeValue = value; + n.nodeType = 3; + return n; + } + createComment(value) { + const n = new MinimalNode("#comment"); + n.nodeValue = value; + n.nodeType = 8; + return n; + } + get body() { + if (!this._body) this._body = this.createElement("body"); + return this._body; + } + get activeElement() { + return null; + } + contains(node) { + return node != null; + } + } + + globalThis.document = new MinimalDocument(); + globalThis.HTMLElement = MinimalNode; + globalThis.HTMLIFrameElement = MinimalNode; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + process.env.IS_REACT_ACT_ENVIRONMENT = "true"; + if (typeof globalThis.window === "undefined") { + Object.defineProperty(globalThis, "window", { + value: globalThis, + configurable: true, + }); + } + if (!Object.getOwnPropertyDescriptor(globalThis, "navigator")?.value) { + Object.defineProperty(globalThis, "navigator", { + value: { userAgent: "node" }, + configurable: true, + }); + } + globalThis.MutationObserver = class { + observe() {} + disconnect() {} + takeRecords() { + return []; + } + }; + globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); +} + +installDOMShim(); + +// ── Tauri IPC interceptor ───────────────────────────────────────────────────── + +/** @type {Array<{ command: string, args: unknown }>} */ +const calls = []; +/** @type {(args: unknown) => Promise} */ +let discoverHandler = () => Promise.resolve([]); + +globalThis.__TAURI_INTERNALS__ = { + invoke: (command, args) => { + calls.push({ command, args }); + if (command === "discover_acp_providers") return discoverHandler(args); + return Promise.reject(new Error(`unmocked Tauri command: ${command}`)); + }, + transformCallback: () => Math.random(), +}; + +// ── Production imports (after shim + IPC stub) ──────────────────────────────── + +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { QueryClient } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; + +import { + acpRuntimesQueryKey, + refreshAcpRuntimes, + useAcpRuntimesQueryForced, +} from "./acpRuntimesQuery.ts"; +import { discoverAcpRuntimes } from "@/shared/api/tauriAcpDiscovery.ts"; + +// ── Wire-shape helper ───────────────────────────────────────────────────────── + +/** A raw discover_acp_providers row (snake_case wire shape). */ +function rawEntry(id, authStatusValue) { + return { + id, + label: id, + avatar_url: "", + availability: "available", + command: id, + binary_path: `/usr/bin/${id}`, + default_args: [], + mcp_command: null, + install_hint: "", + install_instructions_url: "", + can_auto_install: false, + underlying_cli_path: null, + node_required: false, + auth_status: { status: authStatusValue }, + source: "builtin", + }; +} + +function makeQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +/** A promise plus its resolver, for holding a request pending. */ +function deferred() { + let resolve; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +afterEach(() => { + calls.length = 0; + discoverHandler = () => Promise.resolve([]); +}); + +describe("refreshAcpRuntimes cannot dedup onto an in-flight cheap request", () => { + it("runs a distinct force:true probe and writes it into the shared cache", async () => { + const queryClient = makeQueryClient(); + queryClient.mount(); + + // 1. A cheap request (force:false) is in flight and held pending. + const cheap = deferred(); + discoverHandler = (args) => { + if (args?.force === false) return cheap.promise; + // 2. The forced request resolves immediately with distinct data. + return Promise.resolve([rawEntry("codex", "logged_in")]); + }; + + // Start the cheap fetch through the real cheap query path and leave pending. + const cheapFetch = queryClient.fetchQuery({ + queryKey: acpRuntimesQueryKey, + queryFn: () => discoverAcpRuntimes(), + staleTime: 30 * 60_000, + }); + await new Promise((r) => setImmediate(r)); + + // 3. Forced refresh fires while the cheap fetch is still pending. + const forced = await refreshAcpRuntimes(queryClient); + + // 4. Resolve the cheap request afterward; it must not be what the caller got. + cheap.resolve([rawEntry("codex", "unknown")]); + await cheapFetch.catch(() => {}); + + const forceCalls = calls.filter( + (c) => c.command === "discover_acp_providers" && c.args?.force === true, + ); + assert.equal( + forceCalls.length, + 1, + "exactly one forced native probe must have run", + ); + assert.equal(forced[0]?.authStatus.status, "logged_in"); + assert.equal( + queryClient.getQueryData(acpRuntimesQueryKey)?.[0]?.authStatus.status, + "logged_in", + "shared cache must hold the forced result, not the later cheap one", + ); + + queryClient.unmount(); + }); +}); + +describe("useAcpRuntimesQueryForced surfaces forced-probe failures", () => { + it("projects a mount-time forced rejection into error with no unhandled rejection", async () => { + const unhandled = []; + const onUnhandled = (err) => unhandled.push(err); + process.on("unhandledRejection", onUnhandled); + + const queryClient = makeQueryClient(); + discoverHandler = (args) => + args?.force === true + ? Promise.reject(new Error("forced probe failed")) + : Promise.resolve([]); + + let latest = null; + function Consumer() { + latest = useAcpRuntimesQueryForced(); + return null; + } + + const container = document.createElement("div"); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Consumer), + ), + ); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + assert.equal( + latest?.error instanceof Error && latest.error.message, + "forced probe failed", + "mount-time forced rejection must surface as the hook's error", + ); + assert.equal( + latest?.isError, + true, + "isError must reflect the forced failure", + ); + + // Drain the microtask queue so any stray rejection would have fired. + await new Promise((r) => setTimeout(r, 10)); + process.off("unhandledRejection", onUnhandled); + assert.deepEqual( + unhandled, + [], + "no unhandled rejection may escape the fire-and-forget mount force", + ); + + await act(async () => { + root.unmount(); + }); + }); + + it("surfaces an explicit-refresh rejection and clears it on the next success", async () => { + const unhandled = []; + const onUnhandled = (err) => unhandled.push(err); + process.on("unhandledRejection", onUnhandled); + + const queryClient = makeQueryClient(); + let failForced = true; + discoverHandler = (args) => { + if (args?.force !== true) return Promise.resolve([]); + return failForced + ? Promise.reject(new Error("refresh failed")) + : Promise.resolve([rawEntry("codex", "logged_in")]); + }; + + let latest = null; + function Consumer() { + // forceOnMount:false so the only forced probe is the explicit refresh. + latest = useAcpRuntimesQueryForced({ forceOnMount: false }); + return null; + } + + const container = document.createElement("div"); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Consumer), + ), + ); + }); + + // Explicit refresh (button/polling shape): void-called, must not reject. + await act(async () => { + void latest.forceRefresh(); + await new Promise((r) => setTimeout(r, 50)); + }); + assert.equal( + latest?.error instanceof Error && latest.error.message, + "refresh failed", + "explicit-refresh rejection must surface as the hook's error", + ); + + // A subsequent successful refresh clears the error and delivers data. + failForced = false; + await act(async () => { + void latest.forceRefresh(); + await new Promise((r) => setTimeout(r, 50)); + }); + assert.equal( + latest?.error, + null, + "a later successful refresh clears the error", + ); + assert.equal( + queryClient.getQueryData(acpRuntimesQueryKey)?.[0]?.authStatus.status, + "logged_in", + "successful refresh writes the fresh catalog into the shared cache", + ); + + await new Promise((r) => setTimeout(r, 10)); + process.off("unhandledRejection", onUnhandled); + assert.deepEqual( + unhandled, + [], + "no unhandled rejection may escape a void forceRefresh() call", + ); + + await act(async () => { + root.unmount(); + }); + }); +}); + +describe("useAcpRuntimesQueryForced force-on-mount ownership", () => { + it("a later-mounted row does not fire a second forced probe", async () => { + const queryClient = makeQueryClient(); + discoverHandler = () => Promise.resolve([rawEntry("codex", "logged_in")]); + + // Onboarding's real sequence: the surface owner mounts and forces discovery; + // once its result renders, per-runtime rows mount. A row that shared the + // owner's default force-on-mount would fire a *second*, sequential forced + // probe (forced-key dedup cannot collapse it — the owner's fetch is already + // idle). Rows pass forceOnMount:false to consume shared state only. + function Owner() { + useAcpRuntimesQueryForced(); + return null; + } + function Row() { + useAcpRuntimesQueryForced({ forceOnMount: false }); + return null; + } + + const container = document.createElement("div"); + const root = createRoot(container); + + // 1. Owner mounts and forces once; let the probe settle. + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Owner), + ), + ); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + const afterOwner = calls.filter( + (c) => c.command === "discover_acp_providers" && c.args?.force === true, + ).length; + assert.equal(afterOwner, 1, "owner mount must force exactly once"); + + // 2. Rows mount after the owner's result settled; they must not re-probe. + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Owner), + React.createElement(Row), + React.createElement(Row), + React.createElement(Row), + ), + ); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + const forceCalls = calls.filter( + (c) => c.command === "discover_acp_providers" && c.args?.force === true, + ); + assert.equal( + forceCalls.length, + 1, + "later-mounted rows must not trigger a second forced probe", + ); + const cheapCalls = calls.filter( + (c) => c.command === "discover_acp_providers" && c.args?.force === false, + ); + assert.equal( + cheapCalls.length, + 0, + "the forced hook must never fire a cheap fetch (enabled: false observer)", + ); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/desktop/src/features/agents/acpRuntimesQuery.ts b/desktop/src/features/agents/acpRuntimesQuery.ts new file mode 100644 index 0000000000..0e76e25ee7 --- /dev/null +++ b/desktop/src/features/agents/acpRuntimesQuery.ts @@ -0,0 +1,135 @@ +import * as React from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import { discoverAcpRuntimes } from "@/shared/api/tauriAcpDiscovery"; + +/** + * Shared React Query key for the ACP runtime catalog. Every consumer (cheap or + * forced) reads and writes this one entry, so a forced refresh updates the same + * cache the hot-path `useAcpRuntimesQuery` renders from. + */ +export const acpRuntimesQueryKey = ["acp-runtimes"] as const; + +/** + * Separate key for the forced (full re-discovery) fetch. Forced refresh runs on + * *this* key, never the shared cheap key, so React Query's `fetchQuery` can + * never deduplicate a forced probe onto an in-flight cheap request for the + * shared key. The forced result is then written into the shared cache + * deliberately (see `refreshAcpRuntimes`). + */ +export const acpRuntimesForcedQueryKey = ["acp-runtimes", "forced"] as const; + +/** + * Run a forced (full re-discovery) refresh and write the result into the shared + * runtime-catalog cache. + * + * This is the only path that pays the expensive discovery pipeline (cache + * clear, PATH re-fetch, CLI auth probes). Surfaces that need fresh state call + * it deliberately: Settings/onboarding on open and on their refresh buttons, + * and the connect/install/save/delete mutations in `onSettled`. A bare + * `invalidateQueries` would only re-run the cheap query path and never + * re-probe, so the freshly-changed auth/catalog state would not be reflected. + * + * The forced fetch runs on its own key so it can never coalesce onto an + * in-flight *cheap* request for the shared key (which would satisfy the caller + * with cached availability and never run the `{ force: true }` probe). Its + * result is then written into the shared cache with `setQueryData` so hot + * surfaces rendering `useAcpRuntimesQuery` re-render with the fresh catalog. + * Concurrent forced callers still dedup on the forced key; the backend + * coalesces overlapping forced runs as a second layer. + */ +export async function refreshAcpRuntimes( + queryClient: ReturnType, +) { + try { + const result = await queryClient.fetchQuery({ + queryKey: acpRuntimesForcedQueryKey, + queryFn: () => discoverAcpRuntimes({ force: true }), + staleTime: 0, + gcTime: 0, + }); + queryClient.setQueryData(acpRuntimesQueryKey, result); + // A hot-surface cheap fetch may already be in flight on the shared key; cancel + // it so its (older, cached) result cannot land after and clobber the fresh + // forced catalog we just wrote. + await queryClient.cancelQueries({ queryKey: acpRuntimesQueryKey }); + return result; + } catch { + // The forced probe rejected. `fetchQuery` has already recorded the error in + // the forced key's query state, where `useAcpRuntimesQueryForced` projects + // it into the hook's returned `error`/`isError`. Swallow the rejection here + // — at the single source — so the many fire-and-forget callers (mount, + // sign-in polling, refresh buttons, and the four mutation `onSettled` + // paths) can keep `void refreshAcpRuntimes(...)` without ever leaking an + // unhandled rejection, and a new call site can never reintroduce one. The + // shared cache is left untouched so consumers keep the last good catalog + // alongside the surfaced error. + return undefined; + } +} + +/** + * ACP runtimes query for surfaces that need fresh auth/version state: Settings + * harness panels and onboarding. + * + * It reads the shared runtime catalog (`enabled: false`, so it never fires its + * own cheap fetch — the forced probe below is the only fetcher) and re-renders + * whenever `refreshAcpRuntimes` writes a fresh catalog into that cache. Loading + * *and error* state are taken from a disabled observer on the forced key, so + * refresh buttons and the onboarding spinner reflect the forced probe and a + * failed probe surfaces as `error`/`isError` rather than a silent empty + * catalog. `forceRefresh` drives explicit refresh buttons and sign-in + * polling. + * + * `forceOnMount` (default `true`) is the surface owner's one force-on-mount. + * Child rows that share the same surface must pass `forceOnMount: false`: they + * consume the shared query state and the `forceRefresh` callback, but must not + * mount a *second* force effect. Each mounted force effect is a distinct forced + * probe, so an owner + N rows would otherwise re-run the 20–65s pipeline N+1 + * times on entry (and race the catalog to a later state before the owner's + * first result renders). + */ +export function useAcpRuntimesQueryForced(options?: { + enabled?: boolean; + forceOnMount?: boolean; +}) { + const enabled = options?.enabled ?? true; + const forceOnMount = options?.forceOnMount ?? true; + const queryClient = useQueryClient(); + const query = useQuery({ + queryKey: acpRuntimesQueryKey, + queryFn: () => discoverAcpRuntimes(), + staleTime: 30 * 60_000, + // Read-only observer: the forced refresh is the fetcher for these surfaces, + // so this must never fire a cheap fetch (which would race and could + // overwrite the fresh forced result with cached data). + enabled: false, + }); + // Read-only observer on the forced key so the hook surfaces the forced + // probe's fetching *and error* state. `refreshAcpRuntimes` runs the fetch + // imperatively via `fetchQuery`; this disabled observer never fetches itself + // but reflects that query's state, so a rejected forced probe becomes a + // visible `error`/`isError` instead of an unhandled rejection with a silent + // empty/stale catalog. + const forcedQuery = useQuery({ + queryKey: acpRuntimesForcedQueryKey, + queryFn: () => discoverAcpRuntimes({ force: true }), + enabled: false, + }); + const forceRefresh = React.useCallback( + () => refreshAcpRuntimes(queryClient), + [queryClient], + ); + React.useEffect(() => { + if (enabled && forceOnMount) void forceRefresh(); + }, [enabled, forceOnMount, forceRefresh]); + const isFetching = query.isFetching || forcedQuery.isFetching; + return { + ...query, + error: forcedQuery.error ?? query.error, + isError: forcedQuery.isError || query.isError, + isFetching, + isLoading: isFetching && query.data === undefined, + forceRefresh, + }; +} diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 6d8ab4f6ea..5d0be06109 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -25,7 +25,6 @@ import { createManagedAgent, deleteManagedAgent, deleteCustomHarness, - discoverAcpRuntimes, discoverBackendProviders, discoverGitBashPrerequisite, discoverManagedAgentPrereqs, @@ -43,6 +42,7 @@ import { updateManagedAgent, } from "@/shared/api/tauri"; import type { HarnessDefinitionInput } from "@/shared/api/tauri"; +import { discoverAcpRuntimes } from "@/shared/api/tauriAcpDiscovery"; import { setManagedAgentAutoRestart, setManagedAgentStartOnAppLaunch, @@ -50,6 +50,11 @@ import { stopManagedAgent, } from "@/shared/api/tauriManagedAgents"; import { bootstrapManagedAgentRuntimePairs } from "@/features/agents/managedAgentRuntimeHooks"; +import { + acpRuntimesQueryKey, + refreshAcpRuntimes, +} from "@/features/agents/acpRuntimesQuery"; +export { useAcpRuntimesQueryForced } from "@/features/agents/acpRuntimesQuery"; import { createPersona, deletePersona, @@ -123,7 +128,6 @@ export const managedAgentLogFocusRefetchPolicy = { export const relayAgentsQueryKey = ["relay-agents"] as const; export const managedAgentsQueryKey = ["managed-agents"] as const; export const personasQueryKey = ["personas"] as const; -export const acpRuntimesQueryKey = ["acp-runtimes"] as const; export const acpAuthMethodsQueryKey = ["acp-auth-methods"] as const; export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const; export const backendProvidersQueryKey = ["backend-providers"] as const; @@ -199,12 +203,26 @@ function invalidateManagedAgentQueriesInBackground( ); } +/** + * Discover the ACP runtime catalog. + * + * This always serves the **cheap** backend path: the last cached runtime + * availability + auth statuses, no process spawns, low-millisecond. Hot + * surfaces (channel switch, composer, member bar) render from cache — a + * 30-minute `staleTime` keeps channel switches from re-triggering discovery. + * + * Fresh auth/version state (Settings, onboarding sign-in, post-mutation) comes + * from `refreshAcpRuntimes`, which runs the expensive forced path explicitly + * and writes the result into this same cache. Keeping the query's own + * `queryFn` cheap guarantees an automatic staleness refetch never re-runs the + * probe pipeline. + */ export function useAcpRuntimesQuery(options?: { enabled?: boolean }) { return useQuery({ enabled: options?.enabled ?? true, queryKey: acpRuntimesQueryKey, - queryFn: discoverAcpRuntimes, - staleTime: 60_000, + queryFn: () => discoverAcpRuntimes(), + staleTime: 30 * 60_000, }); } @@ -238,7 +256,7 @@ export function useConnectAcpRuntimeMutation() { mutationFn: (input: { runtimeId: string; methodId: string }) => connectAcpRuntime(input.runtimeId, input.methodId), onSettled: () => { - void queryClient.invalidateQueries({ queryKey: acpRuntimesQueryKey }); + void refreshAcpRuntimes(queryClient); void queryClient.invalidateQueries({ queryKey: acpAuthMethodsQueryKey }); void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); }, @@ -250,7 +268,7 @@ export function useInstallAcpRuntimeMutation() { return useMutation({ mutationFn: (runtimeId: string) => installAcpRuntime(runtimeId), onSettled: () => { - void queryClient.invalidateQueries({ queryKey: acpRuntimesQueryKey }); + void refreshAcpRuntimes(queryClient); void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); }, }); @@ -267,7 +285,7 @@ export function useSaveCustomHarnessMutation() { originalId?: string; }) => saveCustomHarness(definition, originalId), onSettled: () => { - void queryClient.invalidateQueries({ queryKey: acpRuntimesQueryKey }); + void refreshAcpRuntimes(queryClient); }, }); } @@ -277,7 +295,7 @@ export function useDeleteCustomHarnessMutation() { return useMutation({ mutationFn: (id: string) => deleteCustomHarness(id), onSettled: () => { - void queryClient.invalidateQueries({ queryKey: acpRuntimesQueryKey }); + void refreshAcpRuntimes(queryClient); }, }); } diff --git a/desktop/src/features/onboarding/ui/SetupStep.acpForcedGate.test.mjs b/desktop/src/features/onboarding/ui/SetupStep.acpForcedGate.test.mjs new file mode 100644 index 0000000000..c360256438 --- /dev/null +++ b/desktop/src/features/onboarding/ui/SetupStep.acpForcedGate.test.mjs @@ -0,0 +1,433 @@ +/** + * Mounted consumer regressions for the SetupStep forced-probe readiness gate. + * + * P1: isChecking = isFetching (not isLoading) ensures the Next button stays + * disabled while the forced probe is in flight or has rejected, even when + * cached data exists. With the old isLoading mapping, isLoading is false when + * data is present, so the button was incorrectly enabled. + * + * Mutation proof: revert only the SetupStep.tsx hunk and both tests go RED + * (button enabled in states it must block). + */ + +import assert from "node:assert/strict"; +import { after, afterEach, before, describe, it } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +Object.assign(globalThis, { + HTMLElement: dom.window.HTMLElement, + HTMLIFrameElement: dom.window.HTMLIFrameElement, + IS_REACT_ACT_ENVIRONMENT: true, + MutationObserver: dom.window.MutationObserver, + ResizeObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + document: dom.window.document, + localStorage: dom.window.localStorage, + self: dom.window, + window: dom.window, +}); +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, +}); +dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); +globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; +dom.window.ResizeObserver = globalThis.ResizeObserver; +dom.window.matchMedia ??= (query) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, +}); +globalThis.matchMedia = dom.window.matchMedia; + +// ── Tauri IPC stub ──────────────────────────────────────────────────────────── + +let discoverHandler = () => Promise.resolve([]); + +globalThis.__TAURI_INTERNALS__ = { + invoke: (command, args) => { + if (command === "discover_acp_providers") return discoverHandler(args); + // All other commands (e.g. plugin:event|listen from useInstallOutputLine) + // reject; useInstallOutputLine catches gracefully ("event system unavailable"). + return Promise.reject(new Error(`unmocked: ${command}`)); + }, + transformCallback: () => 1, +}; +dom.window.__TAURI_INTERNALS__ = globalThis.__TAURI_INTERNALS__; + +// ── Deferred imports (must run after globalThis is configured) ──────────────── + +let React, + act, + createRoot, + QueryClient, + QueryClientProvider, + SetupStep, + acpRuntimesQueryKey, + TooltipProvider; + +before(async () => { + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ SetupStep } = await import("./SetupStep.tsx")); + ({ acpRuntimesQueryKey } = await import( + "@/features/agents/acpRuntimesQuery.ts" + )); + ({ TooltipProvider } = await import("@/shared/ui/tooltip.tsx")); +}); + +afterEach(() => { + discoverHandler = () => Promise.resolve([]); +}); + +after(() => dom.window.close()); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Camelcase AcpRuntimeCatalogEntry as stored in acpRuntimesQueryKey cache. */ +function catalogEntry(id, authStatusValue) { + return { + id, + label: id, + avatarUrl: "", + availability: "available", + command: id, + binaryPath: `/usr/bin/${id}`, + defaultArgs: [], + mcpCommand: null, + modelEnvVar: null, + providerEnvVar: null, + thinkingEnvVar: null, + maxTokensEnvVar: null, + contextLimitEnvVar: null, + maxRoundsEnvVar: null, + installHint: "", + installInstructionsUrl: "", + canAutoInstall: false, + requiresExternalCli: false, + underlyingCliPath: null, + nodeRequired: false, + authStatus: { status: authStatusValue }, + loginHint: null, + source: "builtin", + definitionEnv: {}, + }; +} + +/** Raw snake_case backend entry as `discoverAcpRuntimes` receives it before + * `fromRawAcpRuntimeCatalogEntry`. Use for values a forced probe resolves at + * the IPC boundary (vs. `catalogEntry` for values seeded directly into cache). */ +function rawReadyEntry(id) { + return { + id, + label: id, + avatar_url: "", + availability: "available", + command: id, + binary_path: `/usr/bin/${id}`, + default_args: [], + mcp_command: null, + install_hint: "", + install_instructions_url: "", + can_auto_install: false, + requires_external_cli: false, + underlying_cli_path: null, + node_required: false, + auth_status: { status: "logged_in" }, + source: "builtin", + }; +} + +function makeQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +function deferred() { + let resolve; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +const NOOP = () => {}; +const ACTIONS = { back: NOOP, next: NOOP, navigateToAgentSettings: NOOP }; + +/** Mount SetupStep under the query client + tooltip provider it requires. */ +function renderSetupStep() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + return { container, root }; +} + +function setupStepTree(queryClient) { + return React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + TooltipProvider, + null, + React.createElement(SetupStep, { + actions: ACTIONS, + direction: "forward", + onReadyRuntimeIdsChange: NOOP, + }), + ), + ); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("SetupStep Next button readiness gate — P1 regression (mounted consumer)", () => { + it("onboarding-setup-next is disabled while forced probe is pending over cached data", async () => { + const queryClient = makeQueryClient(); + // Pre-seed cache with a ready runtime. getReadyOnboardingRuntimes + // will return it, so readyRuntimeIds.length > 0 — proving the button + // is blocked by isChecking, not by an empty ready set. + queryClient.setQueryData(acpRuntimesQueryKey, [ + catalogEntry("codex", "logged_in"), + ]); + + const pending = deferred(); + discoverHandler = (args) => + args?.force === true ? pending.promise : Promise.resolve([]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + TooltipProvider, + null, + React.createElement(SetupStep, { + actions: ACTIONS, + direction: "forward", + onReadyRuntimeIdsChange: NOOP, + }), + ), + ), + ); + }); + // Let the mount-time forceRefresh dispatch (but not resolve). + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + const button = container.querySelector( + '[data-testid="onboarding-setup-next"]', + ); + assert.ok(button, "onboarding-setup-next button must be present"); + assert.ok( + button.disabled, + "Next button must be disabled while forced probe is in flight over cached data", + ); + + // Resolve the pending probe inside act so React Query drains its state + // update before unmount — prevents "Promise resolution still pending" + // from the dangling deferred. + await act(async () => { + pending.resolve([]); + await new Promise((r) => setTimeout(r, 0)); + }); + await act(async () => { + root.unmount(); + }); + container.remove(); + queryClient.clear(); + }); + + it("onboarding-setup-next is disabled after forced probe rejects over cached data", async () => { + const queryClient = makeQueryClient(); + queryClient.setQueryData(acpRuntimesQueryKey, [ + catalogEntry("codex", "logged_in"), + ]); + + discoverHandler = (args) => + args?.force === true + ? Promise.reject(new Error("forced probe rejected")) + : Promise.resolve([]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + TooltipProvider, + null, + React.createElement(SetupStep, { + actions: ACTIONS, + direction: "forward", + onReadyRuntimeIdsChange: NOOP, + }), + ), + ), + ); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + const button = container.querySelector( + '[data-testid="onboarding-setup-next"]', + ); + assert.ok(button, "onboarding-setup-next button must be present"); + assert.ok( + button.disabled, + "Next button must be disabled after forced probe rejects, even with cached data", + ); + + const errorEl = container.querySelector( + '[data-testid="onboarding-setup-error"]', + ); + assert.ok( + errorEl, + "the forced rejection error must be rendered after the probe rejects", + ); + assert.match( + errorEl.textContent ?? "", + /forced probe rejected/, + "rendered error must surface the forced rejection message", + ); + + await act(async () => { + root.unmount(); + }); + container.remove(); + queryClient.clear(); + }); +}); + +describe("SetupStep cached-ready revalidation — P4 regression (mounted consumer)", () => { + it("cached READY is replaced by a CHECKING indicator while a warm forced probe is pending", async () => { + const queryClient = makeQueryClient(); + queryClient.setQueryData(acpRuntimesQueryKey, [ + catalogEntry("codex", "logged_in"), + ]); + + const pending = deferred(); + discoverHandler = (args) => + args?.force === true ? pending.promise : Promise.resolve([]); + + const { container, root } = renderSetupStep(); + await act(async () => { + root.render(setupStepTree(queryClient)); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + assert.ok( + container.querySelector( + '[data-testid="onboarding-runtime-rechecking-codex"]', + ), + "a pending warm recheck over a cached-ready runtime must show CHECKING…", + ); + assert.equal( + container.querySelector('[data-testid="onboarding-runtime-ready-codex"]'), + null, + "cached READY must not be presented as current while the recheck is in flight", + ); + + // Success restores READY. + await act(async () => { + pending.resolve([rawReadyEntry("codex")]); + await new Promise((r) => setTimeout(r, 50)); + }); + assert.ok( + container.querySelector('[data-testid="onboarding-runtime-ready-codex"]'), + "READY returns once the warm recheck succeeds", + ); + assert.equal( + container.querySelector( + '[data-testid="onboarding-runtime-rechecking-codex"]', + ), + null, + "the CHECKING indicator clears on success", + ); + const button = container.querySelector( + '[data-testid="onboarding-setup-next"]', + ); + assert.ok(button && !button.disabled, "Next is enabled after success"); + + await act(async () => { + root.unmount(); + }); + container.remove(); + queryClient.clear(); + }); + + it("cached READY is replaced by a recheck affordance after a warm forced probe rejects", async () => { + const queryClient = makeQueryClient(); + queryClient.setQueryData(acpRuntimesQueryKey, [ + catalogEntry("codex", "logged_in"), + ]); + + discoverHandler = (args) => + args?.force === true + ? Promise.reject(new Error("warm recheck failed")) + : Promise.resolve([]); + + const { container, root } = renderSetupStep(); + await act(async () => { + root.render(setupStepTree(queryClient)); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + assert.ok( + container.querySelector( + '[data-testid="onboarding-runtime-recheck-codex"]', + ), + "a warm rejection over a cached-ready runtime must offer a recheck, not claim READY", + ); + assert.equal( + container.querySelector('[data-testid="onboarding-runtime-ready-codex"]'), + null, + "cached READY must not be presented as current after the recheck rejects", + ); + assert.ok( + container.querySelector('[data-testid="onboarding-setup-error"]'), + "the warm rejection error stays visible alongside the retained card", + ); + const button = container.querySelector( + '[data-testid="onboarding-setup-next"]', + ); + assert.ok( + button && button.disabled, + "Next stays gated while readiness is unconfirmed", + ); + + await act(async () => { + root.unmount(); + }); + container.remove(); + queryClient.clear(); + }); +}); diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 2a3476b2ea..aac9b53846 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -4,7 +4,7 @@ import { Check, Info } from "lucide-react"; import { useAcpAuthMethodsQuery, - useAcpRuntimesQuery, + useAcpRuntimesQueryForced, useConnectAcpRuntimeMutation, useInstallAcpRuntimeMutation, } from "@/features/agents/hooks"; @@ -51,9 +51,9 @@ type InstallResultState = { type InstallResultsState = Record; function useSetupStepState(): SetupStepState { - const runtimesQuery = useAcpRuntimesQuery(); + const runtimesQuery = useAcpRuntimesQueryForced(); const items = runtimesQuery.data ?? []; - const isChecking = runtimesQuery.isLoading; + const isChecking = runtimesQuery.isFetching; const errorMessage = runtimesQuery.error instanceof Error ? runtimesQuery.error.message : null; @@ -109,7 +109,11 @@ function RuntimeStatus({ runtime.authStatus.status === "logged_out", }); const connectMutation = useConnectAcpRuntimeMutation(); - const runtimesQuery = useAcpRuntimesQuery(); + // Child rows share the surface owner's forced query state + refresh callback + // (`useSetupStepState` owns the single force-on-mount). Each row must not + // mount its own force effect, or onboarding entry re-runs discovery once per + // row instead of once for the surface. + const runtimesQuery = useAcpRuntimesQueryForced({ forceOnMount: false }); const [isWaitingForSignIn, setIsWaitingForSignIn] = React.useState(false); const [didSignInCheckTimeOut, setDidSignInCheckTimeOut] = React.useState(false); @@ -125,7 +129,7 @@ function RuntimeStatus({ if (!isWaitingForSignIn) return; const interval = window.setInterval(() => { - void runtimesQuery.refetch(); + void runtimesQuery.forceRefresh(); }, 2_000); const timeout = window.setTimeout(() => { setIsWaitingForSignIn(false); @@ -136,7 +140,7 @@ function RuntimeStatus({ window.clearInterval(interval); window.clearTimeout(timeout); }; - }, [isWaitingForSignIn, runtimesQuery.refetch]); + }, [isWaitingForSignIn, runtimesQuery.forceRefresh]); const authMethods = getOnboardingAuthMethods( runtime, methodsQuery.data?.methods ?? [], @@ -157,7 +161,7 @@ function RuntimeStatus({ if (didSignInCheckTimeOut) { setDidSignInCheckTimeOut(false); setIsWaitingForSignIn(true); - void runtimesQuery.refetch(); + void runtimesQuery.forceRefresh(); return; } if (!authMethod) { @@ -215,6 +219,40 @@ function RuntimeStatus({ } if (runtimeIsReadyForOnboarding(runtime)) { + // Cached readiness must not read as freshly confirmed while a warm forced + // probe is revalidating (or has rejected) over it. `runtimesQuery` shares + // the surface owner's forced-query state, so its fetching/error flags track + // the in-flight recheck. Pending → a visible CHECKING… state; a warm + // rejection → a recheck affordance (never an unqualified READY). On success + // both clear and READY returns. Next stays gated by isChecking/errorMessage + // in SetupStepContent, so this only governs the per-card claim. + if (runtimesQuery.isFetching) { + return ( +
+ + CHECKING… +
+ ); + } + if (runtimesQuery.isError) { + return ( + + ); + } return ( @@ -244,7 +282,7 @@ function RuntimeStatus({ aria-label={`Check ${runtime.label} again`} className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40" disabled={runtimesQuery.isFetching} - onClick={() => void runtimesQuery.refetch()} + onClick={() => void runtimesQuery.forceRefresh()} type="button" variant="ghost" > @@ -653,7 +691,10 @@ function RuntimeProvidersSection({ )} {errorMessage ? ( -

+

{errorMessage}

) : null} @@ -724,7 +765,11 @@ function SetupStepContent({ + + ) : null} {isLoading ? ( + ) : isColdError ? ( +
+ Couldn't load runtimes. + +
) : filtered.length === 0 ? (

- No runtimes match. + {isSearching ? "No runtimes match." : "No runtimes found."}

) : ( <> diff --git a/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx b/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx index e7254c5e05..2ce769e646 100644 --- a/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx @@ -3,7 +3,7 @@ import { ExternalLink, Plus, RefreshCw } from "lucide-react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { - useAcpRuntimesQuery, + useAcpRuntimesQueryForced, useGitBashPrerequisiteQuery, } from "@/features/agents/hooks"; import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; @@ -85,7 +85,7 @@ function GitBashCard({ * needs multi-step setup, plus the custom-harness form. */ export function HarnessesSettingsPanel() { - const runtimesQuery = useAcpRuntimesQuery(); + const runtimesQuery = useAcpRuntimesQueryForced(); const gitBashQuery = useGitBashPrerequisiteQuery(); const [catalogOpen, setCatalogOpen] = React.useState(false); // Incremented each time the user clicks "Check again" so HarnessRow @@ -119,7 +119,7 @@ export function HarnessesSettingsPanel() { disabled={isRefreshing} onClick={() => { setResetEpoch((e) => e + 1); - void runtimesQuery.refetch(); + void runtimesQuery.forceRefresh(); void gitBashQuery.refetch(); }} size="sm" diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index b31b8fe977..b4f0df6fc0 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -863,12 +863,6 @@ export async function discoverGitBashPrerequisite(): Promise { - return ( - await invokeTauri("discover_acp_providers") - ).map(fromRawAcpRuntimeCatalogEntry); -} - /** Input shape for creating or updating a custom harness. */ export type HarnessDefinitionInput = { id: string; diff --git a/desktop/src/shared/api/tauriAcpDiscovery.ts b/desktop/src/shared/api/tauriAcpDiscovery.ts new file mode 100644 index 0000000000..6dd4a53dbb --- /dev/null +++ b/desktop/src/shared/api/tauriAcpDiscovery.ts @@ -0,0 +1,24 @@ +import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import { + fromRawAcpRuntimeCatalogEntry, + invokeTauri, + type RawAcpRuntimeCatalogEntry, +} from "@/shared/api/tauri"; + +/** + * Discover the ACP runtime catalog. + * + * `force` defaults to `false` — the cheap backend path that serves cached + * availability + auth statuses with no process spawns. Pass `{ force: true }` + * only from surfaces that need fresh auth/version state (Settings, onboarding, + * post-mutation refresh); that path runs the expensive probe pipeline. + */ +export async function discoverAcpRuntimes(options?: { + force?: boolean; +}): Promise { + const raw = await invokeTauri( + "discover_acp_providers", + { force: options?.force ?? false }, + ); + return raw.map(fromRawAcpRuntimeCatalogEntry); +} From c6e3fe7dfa751096d8c4083aaf15d6f6af296572 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 20 Aug 2026 19:44:21 -0400 Subject: [PATCH 05/38] feat(workflows): reply in-thread from send_message action (#6178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `send_message` workflow action could only post new top-level channel messages. This adds a `reply_in_thread` option so a `message_posted` workflow can reply in the triggering message's thread, plus a `trigger_is_reply` filter variable so a workflow can fire only on top-level messages. Every NIP-10 thread-marker reader in the tree now routes through one shared parser and one collapse rule in `buzz-core`, deleting four hand-rolled copies that had drifted on id-validity and marker semantics. ## What changed - **Schema** (`crates/buzz-workflow/src/schema.rs`): new `reply_in_thread: bool` (serde default `false`) on `ActionDef::SendMessage`. `validate()` rejects `reply_in_thread: true` on `schedule`/`webhook` triggers — they carry no message to reply to. - **Executor** (`crates/buzz-workflow/src/executor.rs`): threads `reply_to: Option<&str>` (the trigger `message_id`) through `ActionSink::send_message` when `reply_in_thread` is set; errors clearly if the trigger has no `message_id`. `resolve_step_templates` carries the new field. Adds `trigger_is_reply` (boolean) to the eval context. - **Relay sink** (`crates/buzz-relay/src/workflow_sink.rs`): when `reply_to` is set, resolves parent/root/depth from the known trigger event, persists real thread metadata instead of the hardcoded top-level `depth: 0`, and pushes the live kind:39005 thread-summary overlay after insert so subscribed desktops update the root's reply badge without refetching — matching the human ingest path. Emits the same NIP-10 `e`-tag shape as `buzz_sdk::builders::thread_tags`: a single `["e", id, "", "reply"]` tag for a direct reply (parent == root), and the `root` + `reply` pair only when nested — so every writer produces one wire shape per reply kind. - **Ingest resolver** (`crates/buzz-relay/src/handlers/ingest.rs`): new `resolve_relay_reply_thread_meta` + `ReplyAncestry`. Unlike the client-facing `resolve_nip10_thread_meta` (which validates client-supplied tags), this computes root/depth from the known parent and enforces the same same-channel and depth-limit invariants. When the parent has no `thread_metadata` row, both resolvers share `derive_ancestry_from_parent_tags`, which applies `ThreadMarkers::resolve()`: a marked nested parent remains depth 2, while a root-only, malformed, or unmarked parent is top-level and starts its reply thread at itself. Both the client resolver and the parent-ancestor fallback now read markers via the shared parser instead of hand-scanning `e`-tags. - **Shared NIP-10 parser** (`crates/buzz-core/src/nip10.rs`): `parse_thread_markers` reads an event's `root`/`reply` markers, honoring a marker only when its event id is exactly 64 ASCII-hex characters. `ThreadMarkers::resolve()` is the single definition of the (root, reply) → (root_id, parent_id) collapse: `root`+`reply` as-is, `reply`-only → `(reply, reply)`, a lone `root` or neither → top-level. A slice-based `parse_thread_markers_from_parts` entry point serves consumers holding raw JSON tag arrays. Relay ingest, the workflow `trigger_is_reply` predicate, ACP anchoring, and the CLI reply resolver all call these, so they cannot drift on marker, id-validity, or collapse semantics. - **ACP** (`crates/buzz-acp/src/queue.rs`): `parse_thread_tags` delegates marker parsing and collapse to `buzz_core::nip10`, keeping only its local `p`-tag mention collection. This fixes a parity gap with ingest: a malformed non-64-hex marker id is no longer counted as a thread link, and a lone `root` marker is now top-level rather than being treated as root == parent. - **CLI** (`crates/buzz-cli/src/commands/messages.rs`): `find_root_from_tags` routes its JSON tags through the shared slice parser and collapse rule. CLI replies to root-only, malformed, or unmarked parents now correctly start at that immediate parent; reply-only and root+reply parent ancestry remain intact. - **Trigger context** (`crates/buzz-workflow/src/lib.rs`): `build_trigger_context` derives `is_reply` solely from a valid NIP-10 `reply` marker (no DB hit). A lone `root` marker is top-level to ingest, so it does not count; neither does a `reply` marker whose event id is malformed, nor a bare/unmarked `e`-tag (mentions, quotes). - **Desktop** (`workflowFormTypes.ts`, `WorkflowStepCard.tsx`): `replyInThread` form field with YAML round-trip, and a "Reply to triggering message in thread" checkbox shown only for message-based triggers. Switching to `schedule` or `webhook` clears the option on every step before serialization, so a value hidden behind an action change cannot resurrect when the action is switched back. The form parser also requires `reply_in_thread` to be a boolean and refuses `reply_in_thread: true` under an ineligible trigger, falling back to YAML mode rather than silently normalizing a backend-invalid definition. ## Usage ```yaml trigger: on: message_posted filter: trigger_is_reply == false steps: - id: auto_reply action: send_message text: "pre-written reply text" reply_in_thread: true ``` --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- crates/buzz-acp/src/queue.rs | 127 ++--- crates/buzz-cli/src/commands/messages.rs | 104 +++-- crates/buzz-core/src/lib.rs | 2 + crates/buzz-core/src/nip10.rs | 197 ++++++++ crates/buzz-relay/src/handlers/ingest.rs | 249 +++++++--- crates/buzz-relay/src/workflow_sink.rs | 439 +++++++++++++++++- crates/buzz-test-client/tests/e2e_relay.rs | 106 +++++ crates/buzz-workflow/src/action_sink.rs | 4 + crates/buzz-workflow/src/executor.rs | 101 +++- crates/buzz-workflow/src/lib.rs | 152 ++++++ crates/buzz-workflow/src/schema.rs | 105 +++++ .../workflows/ui/WorkflowFormBuilder.tsx | 19 +- .../workflows/ui/WorkflowStepCard.tsx | 17 + .../workflows/ui/workflowFormTypes.test.mjs | 119 +++++ .../workflows/ui/workflowFormTypes.ts | 47 +- 15 files changed, 1606 insertions(+), 182 deletions(-) create mode 100644 crates/buzz-core/src/nip10.rs diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index b0f0fa248e..b50f926d8b 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -875,48 +875,31 @@ pub struct ThreadTags { /// Parse NIP-10 thread tags from a Nostr event. /// -/// Detection logic (per research doc §4c): -/// - Find an `e` tag with `root` marker → its value is `root_event_id` -/// - Find an `e` tag with `reply` marker → its value is `parent_event_id` -/// - If only `reply` marker found (direct reply to root), root == parent -/// - `p` tags → mentioned pubkeys +/// Marker parsing and the (root, reply) → (root, parent) collapse are delegated +/// to [`buzz_core::nip10`] so ACP anchoring reads ancestry exactly as relay +/// ingest does. Only `p`-tag mention collection is local to ACP. /// -/// NOTE: Only handles NIP-10 marker-based format (preferred). The deprecated -/// positional format (no markers, `["e", id, relay_url]`) is not supported — -/// Buzz always generates marker-based tags (see relay messages.rs:762-783). +/// Consequences of sharing the resolver: +/// - A malformed (non-64-hex) marker id is ignored, never a thread link — +/// restoring parity with ingest (ACP previously counted it). +/// - A lone `root` marker (no `reply`) is top-level, not a reply — again +/// matching ingest. pub fn parse_thread_tags(event: &Event) -> ThreadTags { - let mut root = None; - let mut reply = None; - let mut mentions = Vec::new(); - - for tag in event.tags.iter() { - let parts = tag.as_slice(); - match parts.first().map(|s| s.as_str()) { - Some("e") if parts.len() >= 4 => { - let id = &parts[1]; - let marker = &parts[3]; - match marker.as_str() { - "root" => root = Some(id.clone()), - "reply" => reply = Some(id.clone()), - _ => {} - } - } - Some("p") if parts.len() >= 2 => { - mentions.push(parts[1].clone()); - } - _ => {} - } - } - - // For direct replies to root: single "reply" tag, no "root" tag. - // In that case, root == parent. - let (root_event_id, parent_event_id) = match (root, reply) { - (Some(r), Some(p)) => (Some(r), Some(p)), - (Some(r), None) => (Some(r.clone()), Some(r)), - (None, Some(p)) => (Some(p.clone()), Some(p)), - (None, None) => (None, None), + let markers = buzz_core::nip10::parse_thread_markers(&event.tags); + let (root_event_id, parent_event_id) = match markers.resolve() { + Some((root, parent)) => (Some(root), Some(parent)), + None => (None, None), }; + let mentions = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 2 && parts[0] == "p").then(|| parts[1].clone()) + }) + .collect(); + ThreadTags { root_event_id, parent_event_id, @@ -3192,28 +3175,31 @@ mod tests { #[test] fn test_parse_thread_tags_direct_reply() { // Direct reply to root: single "reply" tag. + let root = "a".repeat(64); let event = make_event_with_tags( "reply to root", - vec![vec!["e".into(), "abc123".into(), "".into(), "reply".into()]], + vec![vec!["e".into(), root.clone(), "".into(), "reply".into()]], ); let tags = parse_thread_tags(&event); - assert_eq!(tags.root_event_id.as_deref(), Some("abc123")); - assert_eq!(tags.parent_event_id.as_deref(), Some("abc123")); + assert_eq!(tags.root_event_id.as_deref(), Some(root.as_str())); + assert_eq!(tags.parent_event_id.as_deref(), Some(root.as_str())); } #[test] fn test_parse_thread_tags_nested_reply() { // Nested reply: root + reply tags. + let root = "a".repeat(64); + let parent = "b".repeat(64); let event = make_event_with_tags( "nested reply", vec![ - vec!["e".into(), "root123".into(), "".into(), "root".into()], - vec!["e".into(), "parent456".into(), "".into(), "reply".into()], + vec!["e".into(), root.clone(), "".into(), "root".into()], + vec!["e".into(), parent.clone(), "".into(), "reply".into()], ], ); let tags = parse_thread_tags(&event); - assert_eq!(tags.root_event_id.as_deref(), Some("root123")); - assert_eq!(tags.parent_event_id.as_deref(), Some("parent456")); + assert_eq!(tags.root_event_id.as_deref(), Some(root.as_str())); + assert_eq!(tags.parent_event_id.as_deref(), Some(parent.as_str())); } #[test] @@ -3231,15 +3217,36 @@ mod tests { } #[test] - fn test_parse_thread_tags_root_only() { - // Only root marker, no reply marker — root == parent. + fn test_parse_thread_tags_root_only_is_top_level() { + // Only a `root` marker, no `reply` — top-level, matching ingest. A lone + // `root` tag does not anchor a reply (behavior change from the old + // hand-rolled parser, which treated root == parent here). + let root = "a".repeat(64); let event = make_event_with_tags( - "reply", - vec![vec!["e".into(), "root123".into(), "".into(), "root".into()]], + "root only", + vec![vec!["e".into(), root, "".into(), "root".into()]], ); let tags = parse_thread_tags(&event); - assert_eq!(tags.root_event_id.as_deref(), Some("root123")); - assert_eq!(tags.parent_event_id.as_deref(), Some("root123")); + assert!(tags.root_event_id.is_none()); + assert!(tags.parent_event_id.is_none()); + } + + #[test] + fn test_parse_thread_tags_malformed_id_is_not_a_thread_link() { + // A non-64-hex marker id is ignored — parity with relay ingest, which + // never treats a malformed id as a thread link. + let event = make_event_with_tags( + "malformed marker", + vec![vec![ + "e".into(), + "garbage".into(), + "".into(), + "reply".into(), + ]], + ); + let tags = parse_thread_tags(&event); + assert!(tags.root_event_id.is_none()); + assert!(tags.parent_event_id.is_none()); } #[test] @@ -3312,7 +3319,7 @@ mod tests { "yes go ahead", vec![vec![ "e".into(), - "root123".into(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), "".into(), "reply".into(), ]], @@ -3330,7 +3337,9 @@ mod tests { let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); assert!(prompt.contains("Scope: thread")); - assert!(prompt.contains("Thread root: root123")); + assert!(prompt.contains( + "Thread root: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + )); } #[test] @@ -3340,7 +3349,7 @@ mod tests { "yes go ahead", vec![vec![ "e".into(), - "root123".into(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), "".into(), "reply".into(), ]], @@ -3645,7 +3654,7 @@ mod tests { "sounds good, do it", vec![vec![ "e".into(), - "root123".into(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), "".into(), "reply".into(), ]], @@ -3698,7 +3707,9 @@ mod tests { ); // Thread structural info should be present. assert!( - prompt.contains("Thread root: root123"), + prompt.contains( + "Thread root: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ), "DM reply should include thread root" ); // Thread context should be included. @@ -3712,7 +3723,7 @@ mod tests { "follow up", vec![vec![ "e".into(), - "root123".into(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), "".into(), "reply".into(), ]], @@ -5310,7 +5321,7 @@ mod tests { "reply in thread", vec![vec![ "e".into(), - "root123".into(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), "".into(), "reply".into(), ]], diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 5651c0b4c7..ea273336e3 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -14,36 +14,45 @@ use buzz_sdk::mentions::{ /// Extract the thread root event ID from a Nostr tag array. /// -/// Parses `"e"` tags with NIP-10 markers: -/// - If a `"root"` marker exists, returns that event ID. -/// - Otherwise, if only a `"reply"` marker exists, returns the reply target -/// (a direct reply's parent IS the root, and nested replies need that root -/// to thread correctly). -/// - If no thread markers exist, returns `None` (parent is a top-level message, -/// so it is itself the root). +/// Delegates marker parsing and collapse to [`buzz_core::nip10`] (shared with +/// relay ingest and ACP) so id-validity, marker selection, and top-level +/// classification cannot drift: +/// - A `root`+`reply` parent returns its root event ID. +/// - A `reply`-only parent returns the reply target (a direct reply's parent IS +/// the root). +/// - A root-only or marker-less parent returns `None` (it is top-level and its +/// own root). fn find_root_from_tags(tags: &serde_json::Value) -> Option { - fn valid_event_id(s: &str) -> bool { - s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()) - } - let arr = tags.as_array()?; - let mut root = None; - let mut reply = None; - for tag in arr { - let Some(parts) = tag.as_array() else { - continue; - }; - if parts.len() >= 4 && parts[0].as_str() == Some("e") { - // Defensively ignore malformed marker values so a bad tag on the - // parent event can't block the reply — fall back to root == parent. - let id = parts[1].as_str().filter(|s| valid_event_id(s)); - match (parts[3].as_str(), id) { - (Some("root"), Some(id)) => root = Some(id.to_string()), - (Some("reply"), Some(id)) => reply = Some(id.to_string()), - _ => {} - } - } - } - root.or(reply) + let parts: Vec> = tags + .as_array()? + .iter() + .filter_map(|tag| { + tag.as_array().map(|a| { + a.iter() + .map(|v| v.as_str().unwrap_or("").to_string()) + .collect() + }) + }) + .collect(); + buzz_core::nip10::parse_thread_markers_from_parts(parts.iter().map(Vec::as_slice)) + .resolve() + .map(|(root, _)| root) +} + +fn thread_ref_from_parent_tags( + parent_eid: nostr::EventId, + parent_event_id: &str, + tags: &serde_json::Value, +) -> Result { + let root_eid = match find_root_from_tags(tags) { + Some(root_hex) if root_hex != parent_event_id => parse_event_id(&root_hex)?, + _ => parent_eid, + }; + + Ok(ThreadRef { + root_event_id: root_eid, + parent_event_id: parent_eid, + }) } /// Build a `ThreadRef` for a reply, given the immediate parent's event ID. @@ -80,14 +89,7 @@ fn thread_ref_from_event(event_id: &str, event: &serde_json::Value) -> Result parse_event_id(&root_hex)?, - _ => parent_eid, - }; - Ok(ThreadRef { - root_event_id, - parent_event_id: parent_eid, - }) + thread_ref_from_parent_tags(parent_eid, event_id, &tags) } /// Resolve the channel UUID for an event by querying for it via POST /query. @@ -1057,7 +1059,8 @@ mod tests { channel_id_from_event, cmd_get_thread, event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions, missing_members, normalize_explicit_mentions, parse_member_pubkeys, resolve_names_to_pubkeys, - resolve_thread_target, thread_ref_from_event, BuzzClient, CliError, Uuid, + resolve_thread_target, thread_ref_from_event, thread_ref_from_parent_tags, BuzzClient, + CliError, Uuid, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, @@ -1132,7 +1135,7 @@ mod tests { let channel = "123e4567-e89b-12d3-a456-426614174000"; let other_channel = "123e4567-e89b-12d3-a456-426614174001"; let selected = json!({ - "tags": [["h", channel], ["e", ID_A, "", "root"]] + "tags": [["h", channel], ["e", ID_A, "", "root"], ["e", ID_B, "", "reply"]] }); assert!(resolve_thread_target( @@ -1171,6 +1174,23 @@ mod tests { assert_eq!(find_root_from_tags(&tags).as_deref(), Some(ID_A)); } + #[test] + fn root_marker_without_reply_is_top_level() { + let tags = json!([["e", ID_A, "", "root"], ["p", PUBKEY],]); + assert!(find_root_from_tags(&tags).is_none()); + } + + #[test] + fn root_only_parent_starts_cli_reply_thread_at_parent() { + let tags = json!([["e", ID_A, "", "root"]]); + let parent = nostr::EventId::from_hex(ID_B).expect("valid parent id"); + + let thread_ref = thread_ref_from_parent_tags(parent, ID_B, &tags).expect("thread ref"); + + assert_eq!(thread_ref.parent_event_id, parent); + assert_eq!(thread_ref.root_event_id, parent); + } + #[test] fn reply_only_falls_back_to_reply_target() { // Direct reply to a top-level message — the parent's only e-tag is a @@ -1194,14 +1214,16 @@ mod tests { } #[test] - fn malformed_tags_are_skipped() { + fn malformed_tags_are_skipped_and_root_only_is_top_level() { + // Invalid entries are ignored, leaving a valid root-only marker; the + // shared collapse rule still classifies that parent as top-level. let tags = json!([ "not-an-array", ["e"], ["e", "short"], ["e", ID_A, "", "root"], ]); - assert_eq!(find_root_from_tags(&tags).as_deref(), Some(ID_A)); + assert!(find_root_from_tags(&tags).is_none()); } #[test] diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 7424915c83..36dc772da3 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -26,6 +26,8 @@ pub mod invite; pub mod kind; /// Network utilities — SSRF-safe IP classification. pub mod network; +/// NIP-10 thread-marker parsing — shared `root`/`reply` marker resolver. +pub mod nip10; /// Agent observer frame helpers. pub mod observer; /// NIP-AB device pairing — crypto primitives, message types, and errors. diff --git a/crates/buzz-core/src/nip10.rs b/crates/buzz-core/src/nip10.rs new file mode 100644 index 0000000000..993515f442 --- /dev/null +++ b/crates/buzz-core/src/nip10.rs @@ -0,0 +1,197 @@ +//! Shared NIP-10 thread-marker parsing. +//! +//! One parser for the `root`/`reply` markers on an event's `e` tags, so every +//! consumer reads ancestry the same way. The relay ingest resolver +//! (`resolve_nip10_thread_meta`) and the workflow `trigger_is_reply` predicate +//! both call this — a second hand-rolled copy is exactly how the two drifted on +//! marker semantics and on id-validity. +//! +//! Validity mirrors ingest: a marker counts only when its event id is exactly +//! 64 ASCII-hex characters. A malformed id (e.g. `["e","bad","","reply"]`) is +//! ignored, never treated as a thread link. + +/// The `root` and `reply` event ids parsed from an event's NIP-10 `e` tags. +/// +/// Each is `Some(id_hex)` only when a marker of that kind carried a valid +/// 64-hex event id. The last valid occurrence of each marker wins, matching +/// the relay resolver's single-pass overwrite. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ThreadMarkers { + /// Event id from a valid `["e", <64-hex>, , "root"]` tag. + pub root: Option, + /// Event id from a valid `["e", <64-hex>, , "reply"]` tag. + pub reply: Option, +} + +impl ThreadMarkers { + /// Collapse the `root`/`reply` markers into a reply's `(root_id, parent_id)`. + /// + /// This is the single definition of the NIP-10 resolution rule shared by + /// consumers that classify a reply's own root/parent or recover a parent's + /// ancestry (relay ingest, ACP anchoring, and the CLI). + /// + /// - `root` + `reply` → `(root, reply)` — a nested reply names both. + /// - `reply` only → `(reply, reply)` — a direct reply to the root; the + /// reply target is itself the thread root. + /// - `root` only or neither → `None` — no `reply` marker means the event is + /// top-level, matching ingest (a lone `root` tag never anchors a reply). + pub fn resolve(&self) -> Option<(String, String)> { + match (&self.root, &self.reply) { + (Some(root), Some(reply)) => Some((root.clone(), reply.clone())), + (None, Some(reply)) => Some((reply.clone(), reply.clone())), + (Some(_), None) | (None, None) => None, + } + } +} + +/// Return true when `id` is exactly 64 ASCII-hex characters — the shape a +/// Nostr event id must have to be a real thread link. +fn is_event_id_hex(id: &str) -> bool { + id.len() == 64 && id.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Parse the NIP-10 `root`/`reply` markers from an event's tags. +/// +/// Only `e` tags with a marker (`parts.len() >= 4`) and a valid 64-hex event id +/// are considered; everything else is ignored. +pub fn parse_thread_markers(tags: &nostr::Tags) -> ThreadMarkers { + parse_thread_markers_from_parts(tags.iter().map(nostr::Tag::as_slice)) +} + +/// Same parser as [`parse_thread_markers`], for consumers that hold raw tag +/// arrays (e.g. decoded JSON `tags`) rather than a [`nostr::Tags`]. +/// +/// Each tag is a slice of string-like parts (`["e", , , ]`). +pub fn parse_thread_markers_from_parts<'a, S, I>(tags: I) -> ThreadMarkers +where + S: AsRef + 'a, + I: IntoIterator, +{ + let mut markers = ThreadMarkers::default(); + for parts in tags { + if parts.len() >= 4 && parts[0].as_ref() == "e" && is_event_id_hex(parts[1].as_ref()) { + match parts[3].as_ref() { + "root" => markers.root = Some(parts[1].as_ref().to_string()), + "reply" => markers.reply = Some(parts[1].as_ref().to_string()), + _ => {} + } + } + } + markers +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn markers_for(tags: Vec) -> ThreadMarkers { + let event = EventBuilder::new(Kind::Custom(9), "") + .tags(tags) + .sign_with_keys(&Keys::generate()) + .expect("sign"); + parse_thread_markers(&event.tags) + } + + fn id() -> String { + "a".repeat(64) + } + + #[test] + fn no_e_tags_yields_no_markers() { + assert_eq!(markers_for(vec![]), ThreadMarkers::default()); + } + + #[test] + fn root_and_reply_both_parsed() { + let m = markers_for(vec![ + Tag::parse(["e", &id(), "", "root"]).unwrap(), + Tag::parse(["e", &"b".repeat(64), "", "reply"]).unwrap(), + ]); + assert_eq!(m.root.as_deref(), Some(id().as_str())); + assert_eq!(m.reply.as_deref(), Some("b".repeat(64).as_str())); + } + + #[test] + fn reply_only_marker_parsed() { + let m = markers_for(vec![Tag::parse(["e", &id(), "", "reply"]).unwrap()]); + assert_eq!(m.reply.as_deref(), Some(id().as_str())); + assert!(m.root.is_none()); + } + + #[test] + fn bare_e_tag_without_marker_is_ignored() { + let m = markers_for(vec![Tag::parse(["e", &id()]).unwrap()]); + assert_eq!(m, ThreadMarkers::default()); + } + + #[test] + fn malformed_id_is_ignored_for_both_markers() { + // Ingest gates the marker on a valid 64-hex id; a malformed id is not a + // thread link, so neither marker is set. + let m = markers_for(vec![ + Tag::parse(["e", "bad", "", "reply"]).unwrap(), + Tag::parse(["e", "also-bad", "", "root"]).unwrap(), + ]); + assert_eq!(m, ThreadMarkers::default()); + } + + #[test] + fn valid_root_with_malformed_reply_is_top_level() { + // A valid root but a malformed reply id: reply is ignored, so this is + // top-level to ingest (root-only) and must be so here too. + let m = markers_for(vec![ + Tag::parse(["e", &id(), "", "root"]).unwrap(), + Tag::parse(["e", "bad", "", "reply"]).unwrap(), + ]); + assert_eq!(m.root.as_deref(), Some(id().as_str())); + assert!(m.reply.is_none()); + } + + #[test] + fn resolve_root_and_reply_keeps_both() { + let m = ThreadMarkers { + root: Some("r".repeat(64)), + reply: Some("p".repeat(64)), + }; + assert_eq!(m.resolve(), Some(("r".repeat(64), "p".repeat(64)))); + } + + #[test] + fn resolve_reply_only_is_direct_reply_to_root() { + let m = ThreadMarkers { + root: None, + reply: Some(id()), + }; + assert_eq!(m.resolve(), Some((id(), id()))); + } + + #[test] + fn resolve_root_only_is_top_level() { + let m = ThreadMarkers { + root: Some(id()), + reply: None, + }; + assert_eq!(m.resolve(), None); + } + + #[test] + fn resolve_no_markers_is_top_level() { + assert_eq!(ThreadMarkers::default().resolve(), None); + } + + #[test] + fn parse_from_parts_matches_tags_path() { + // The slice-based entry point must gate id validity and select markers + // identically to the `nostr::Tags` path. + let tags: Vec> = vec![ + vec!["e".into(), id(), "".into(), "root".into()], + vec!["e".into(), "b".repeat(64), "".into(), "reply".into()], + vec!["e".into(), "bad".into(), "".into(), "reply".into()], + vec!["p".into(), "abc".into()], + ]; + let m = parse_thread_markers_from_parts(tags.iter().map(Vec::as_slice)); + assert_eq!(m.root.as_deref(), Some(id().as_str())); + assert_eq!(m.reply.as_deref(), Some("b".repeat(64).as_str())); + } +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 5ba9650e91..9629ffb0fd 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -724,32 +724,11 @@ pub(crate) async fn resolve_nip10_thread_meta( channel_id: Uuid, state: &AppState, ) -> Result, String> { - let mut root_hex: Option = None; - let mut reply_hex: Option = None; + let markers = buzz_core::nip10::parse_thread_markers(&event.tags); - for tag in event.tags.iter() { - let parts = tag.as_slice(); - if parts.len() >= 4 && parts[0] == "e" { - let hex_val = &parts[1]; - let marker = &parts[3]; - if hex_val.len() == 64 && hex_val.chars().all(|c| c.is_ascii_hexdigit()) { - match marker.as_str() { - "root" => root_hex = Some(hex_val.to_string()), - "reply" => reply_hex = Some(hex_val.to_string()), - _ => {} - } - } - } - } - - if root_hex.is_none() && reply_hex.is_none() { - return Ok(None); - } - - let (root_hex, parent_hex) = match (root_hex, reply_hex) { - (Some(r), Some(p)) => (r, p), - (None, Some(p)) => (p.clone(), p), - (Some(_), None) | (None, None) => return Ok(None), + let (root_hex, parent_hex) = match markers.resolve() { + Some(pair) => pair, + None => return Ok(None), }; let parent_bytes = @@ -807,46 +786,18 @@ pub(crate) async fn resolve_nip10_thread_meta( (effective_root, root_ts, depth) } None => { - let parent_root = parent_event - .event - .tags - .iter() - .find_map(|t| { - let parts = t.as_slice(); - if parts.len() >= 4 && parts[0] == "e" && parts[3] == "root" { - hex::decode(&parts[1]).ok().filter(|b| b.len() == 32) - } else { - None - } - }) - .or_else(|| { - parent_event.event.tags.iter().find_map(|t| { - let parts = t.as_slice(); - if parts.len() >= 4 && parts[0] == "e" && parts[3] == "reply" { - hex::decode(&parts[1]).ok().filter(|b| b.len() == 32) - } else { - None - } - }) - }) - .unwrap_or_else(|| parent_bytes.clone()); + let (parent_root, root_created, depth) = derive_ancestry_from_parent_tags( + community_id, + &parent_event.event, + &parent_bytes, + parent_created, + state, + ) + .await; if client_root_bytes != parent_root { return Err("root tag does not match thread ancestry".to_string()); } - let depth = if parent_root == parent_bytes { 1 } else { 2 }; - let root_created = if parent_root != parent_bytes { - if let Ok(Some(root_ev)) = - state.db.get_event_by_id(community_id, &parent_root).await - { - chrono::DateTime::from_timestamp(root_ev.event.created_at.as_secs() as i64, 0) - .unwrap_or(parent_created) - } else { - parent_created - } - } else { - parent_created - }; (parent_root, root_created, depth) } }; @@ -872,6 +823,182 @@ pub(crate) async fn resolve_nip10_thread_meta( })) } +/// Recover a reply's thread ancestry from its *parent's* NIP-10 tags when the +/// parent has **no** `thread_metadata` row (legacy or not-yet-indexed events). +/// +/// The parent's markers are first collapsed through `ThreadMarkers::resolve()`: +/// a `root`+`reply` parent carries its marked root, a `reply`-only parent carries +/// its reply target as root, and a root-only/malformed/unmarked parent is itself +/// top-level and its own root. Depth is 1 when the parent is the root and 2 +/// otherwise — a reply to a nested-but-unindexed parent must not be mistaken for +/// a top-level reply. +/// +/// Shared by [`resolve_nip10_thread_meta`] (client path) and +/// [`resolve_relay_reply_thread_meta`] (workflow path) so the two cannot +/// diverge. Returns `(root_event_id, root_event_created_at, depth)`. +async fn derive_ancestry_from_parent_tags( + community_id: CommunityId, + parent_event: &Event, + parent_bytes: &[u8], + parent_created: chrono::DateTime, + state: &AppState, +) -> (Vec, chrono::DateTime, i32) { + let marked_ancestor = |id_hex: &str| hex::decode(id_hex).ok().filter(|b| b.len() == 32); + let markers = buzz_core::nip10::parse_thread_markers(&parent_event.tags); + let parent_root = markers + .resolve() + .map(|(root, _)| root) + .as_deref() + .and_then(marked_ancestor) + .unwrap_or_else(|| parent_bytes.to_vec()); + + if parent_root.as_slice() == parent_bytes { + (parent_root, parent_created, 1) + } else { + let root_created = + if let Ok(Some(root_ev)) = state.db.get_event_by_id(community_id, &parent_root).await { + chrono::DateTime::from_timestamp(root_ev.event.created_at.as_secs() as i64, 0) + .unwrap_or(parent_created) + } else { + parent_created + }; + (parent_root, root_created, 2) + } +} + +/// Resolved thread ancestry for a relay-built reply (workflow path). +/// +/// Carries the parent and root identifiers plus the reply's depth, so the +/// caller can both emit matching NIP-10 `root`/`reply` tags and persist thread +/// metadata for the signed reply event. +pub(crate) struct ReplyAncestry { + pub parent_event_id: Vec, + pub parent_event_created_at: chrono::DateTime, + pub root_event_id: Vec, + pub root_event_created_at: chrono::DateTime, + pub depth: i32, +} + +impl ReplyAncestry { + /// Root event ID as lowercase hex, for the NIP-10 `root` tag. + pub fn root_hex(&self) -> String { + hex::encode(&self.root_event_id) + } + + /// Parent event ID as lowercase hex, for the NIP-10 `reply` tag. + pub fn parent_hex(&self) -> String { + hex::encode(&self.parent_event_id) + } + + /// Build the DB thread-metadata params for the signed reply event. + pub fn into_thread_meta( + self, + reply_event_id: Vec, + reply_created_at: chrono::DateTime, + channel_id: Uuid, + ) -> ThreadMetadataOwned { + ThreadMetadataOwned { + event_id: reply_event_id, + event_created_at: reply_created_at, + channel_id, + parent_event_id: self.parent_event_id, + parent_event_created_at: self.parent_event_created_at, + root_event_id: self.root_event_id, + root_event_created_at: self.root_event_created_at, + depth: self.depth, + broadcast: false, + } + } +} + +/// Resolve thread ancestry for a reply built by the relay (workflow path). +/// +/// Unlike [`resolve_nip10_thread_meta`], which validates client-supplied NIP-10 +/// `e` tags, this derives ancestry from a known `parent_hex` (the triggering +/// event) and *computes* the correct root and depth. Enforces the same-channel +/// invariant and the depth limit that the ingest path applies. +pub(crate) async fn resolve_relay_reply_thread_meta( + community_id: CommunityId, + parent_hex: &str, + channel_id: Uuid, + state: &AppState, +) -> Result { + let parent_bytes = + hex::decode(parent_hex).map_err(|_| "invalid parent event ID hex".to_string())?; + + let (parent_event_result, parent_meta_result) = tokio::join!( + state.db.get_event_by_id(community_id, &parent_bytes), + state + .db + .get_thread_metadata_by_event(community_id, &parent_bytes), + ); + + let parent_event = parent_event_result + .map_err(|e| format!("db error looking up parent: {e}"))? + .ok_or_else(|| "reply parent not found".to_string())?; + + match parent_event.channel_id { + Some(parent_ch) if parent_ch != channel_id => { + return Err("parent event belongs to a different channel".to_string()); + } + None => return Err("parent event has no channel association".to_string()), + _ => {} + } + + let parent_created = + chrono::DateTime::from_timestamp(parent_event.event.created_at.as_secs() as i64, 0) + .unwrap_or_else(Utc::now); + + let parent_meta = + parent_meta_result.map_err(|e| format!("db error looking up thread metadata: {e}"))?; + + // Root = parent's root if the parent is itself a reply, else the parent. + // Depth = parent depth + 1 (a direct reply to a top-level message is depth 1). + let (root_bytes, root_created, depth) = match parent_meta { + Some(meta) => { + let effective_root = meta.root_event_id.unwrap_or_else(|| parent_bytes.clone()); + let root_ts = if effective_root == parent_bytes { + parent_created + } else if let Ok(Some(root_ev)) = state + .db + .get_event_by_id(community_id, &effective_root) + .await + { + chrono::DateTime::from_timestamp(root_ev.event.created_at.as_secs() as i64, 0) + .unwrap_or(parent_created) + } else { + parent_created + }; + (effective_root, root_ts, meta.depth + 1) + } + // No metadata row ⇒ recover the parent's ancestry from its own NIP-10 + // tags. A marked (but not-yet-indexed) nested parent yields depth 2, not + // a false top-level depth 1. + None => { + derive_ancestry_from_parent_tags( + community_id, + &parent_event.event, + &parent_bytes, + parent_created, + state, + ) + .await + } + }; + + if depth > 100 { + return Err("thread depth limit exceeded".to_string()); + } + + Ok(ReplyAncestry { + parent_event_id: parent_bytes, + parent_event_created_at: parent_created, + root_event_id: root_bytes, + root_event_created_at: root_created, + depth, + }) +} + /// Count all `e` tags regardless of content validity. fn count_e_tags(event: &Event) -> usize { event diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 97c31c2561..8ce23a2e8e 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -176,10 +176,12 @@ impl ActionSink for RelayActionSink { channel_id: &str, text: &str, author_pubkey: &str, + reply_to: Option<&str>, ) -> Pin> + Send + '_>> { let channel_id = channel_id.to_owned(); let text = text.to_owned(); let author_pubkey = author_pubkey.to_owned(); + let reply_to = reply_to.map(str::to_owned); Box::pin(async move { // 0. Upgrade weak reference — fails only during shutdown. @@ -266,6 +268,50 @@ impl ActionSink for RelayActionSink { .map_err(|e| ActionSinkError::EventBuild(format!("workflow tag: {e}")))?, ]; + // Resolve thread ancestry when this is a threaded reply, so the + // built event carries NIP-10 `root`/`reply` e-tags and persists real + // thread metadata (matching the ingest path) instead of top-level. + let reply_ancestry = match reply_to.as_deref() { + Some(parent_hex) => Some( + crate::handlers::ingest::resolve_relay_reply_thread_meta( + tenant.community(), + parent_hex, + channel_uuid, + &state, + ) + .await + .map_err(ActionSinkError::InvalidInput)?, + ), + None => None, + }; + + // NIP-10 e-tags for the thread. Marked `root`/`reply` so clients and + // the ingest resolver read the ancestry the same way. A direct reply + // (parent == root) emits a single `reply` tag; a nested reply emits + // the `root` + `reply` pair — matching `buzz_sdk::builders::thread_tags` + // so every writer produces one wire shape per reply kind. + if let Some(ancestry) = &reply_ancestry { + let root_hex = ancestry.root_hex(); + let parent_hex = ancestry.parent_hex(); + if root_hex == parent_hex { + tags.push( + Tag::parse(["e", &root_hex, "", "reply"]).map_err(|e| { + ActionSinkError::EventBuild(format!("reply e tag: {e}")) + })?, + ); + } else { + tags.push( + Tag::parse(["e", &root_hex, "", "root"]) + .map_err(|e| ActionSinkError::EventBuild(format!("root e tag: {e}")))?, + ); + tags.push( + Tag::parse(["e", &parent_hex, "", "reply"]).map_err(|e| { + ActionSinkError::EventBuild(format!("reply e tag: {e}")) + })?, + ); + } + } + // Resolve `@Name` mentions to channel-member pubkeys and append a // `p` tag for each (skipping the author, already tagged above). A // resolution failure must not drop the message, so log and proceed @@ -321,17 +367,24 @@ impl ActionSink for RelayActionSink { ); // 4. Persist event with thread metadata (matches REST handler path). - // Workflow messages are always top-level: depth=0, no parent/root. - let thread_meta = Some(buzz_db::event::ThreadMetadataParams { - event_id: &event_id_bytes, - event_created_at, - channel_id: channel_uuid, - parent_event_id: None, - parent_event_created_at: None, - root_event_id: None, - root_event_created_at: None, - depth: 0, - broadcast: false, + // Threaded replies persist the resolved parent/root/depth; a + // non-reply workflow message stays top-level (depth=0, no parent). + let thread_meta_owned = reply_ancestry.map(|ancestry| { + ancestry.into_thread_meta(event_id_bytes.clone(), event_created_at, channel_uuid) + }); + let thread_meta = Some(match &thread_meta_owned { + Some(owned) => owned.as_params(), + None => buzz_db::event::ThreadMetadataParams { + event_id: &event_id_bytes, + event_created_at, + channel_id: channel_uuid, + parent_event_id: None, + parent_event_created_at: None, + root_event_id: None, + root_event_created_at: None, + depth: 0, + broadcast: false, + }, }); let (stored_event, was_inserted) = state @@ -357,6 +410,20 @@ impl ActionSink for RelayActionSink { None, ) .await; + + // A threaded reply changed its thread's counters — push a fresh + // relay-signed kind:39005 so subscribed clients update badge + // counts without refetching the head window, exactly as the + // ingest path does after a reply insert. Fan-out-only and + // best-effort; skipped for top-level (non-reply) messages. + if let Some(owned) = &thread_meta_owned { + crate::handlers::side_effects::emit_live_thread_summary( + &tenant, + &state, + channel_uuid, + owned.root_event_id.clone(), + ); + } } Ok(event_id_hex) @@ -676,6 +743,7 @@ mod integration_tests { &channel.id.to_string(), "heads up @Robby — please take a look", &author_hex, + None, ) .await .expect("send_message"); @@ -708,4 +776,353 @@ mod integration_tests { "mentioned member {agent_hex} must be p-tagged so it wakes; got {p_tag_targets:?}" ); } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_reply_in_thread_threads_onto_parent() { + let state = test_state().await; + + let author = nostr::Keys::generate(); + let author_hex = author.public_key().to_hex(); + + let host = format!("wf-thread-{}.example", uuid::Uuid::new_v4().simple()); + let community = match state + .db + .create_community_with_owner(&host, &author_hex) + .await + .expect("create community") + { + CreateCommunityWithOwnerResult::Created(rec) => rec.id, + other => panic!("expected fresh community, got {other:?}"), + }; + + let channel = state + .db + .create_channel( + community, + "wf-thread", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &author.public_key().to_bytes(), + None, + ) + .await + .expect("create channel"); + + let sink = RelayActionSink::new(&state); + + // 1. A top-level workflow message becomes the thread root. + let root_hex = sink + .send_message( + community, + &channel.id.to_string(), + "root message", + &author_hex, + None, + ) + .await + .expect("send root"); + + // 2. A reply_in_thread message threads onto it. + let reply_hex = sink + .send_message( + community, + &channel.id.to_string(), + "threaded reply", + &author_hex, + Some(&root_hex), + ) + .await + .expect("send reply"); + + // A direct reply carries a single NIP-10 reply e-tag at the root (no + // root marker), matching SDK `thread_tags`. + let reply_id_bytes = nostr::EventId::from_hex(&reply_hex) + .expect("reply id") + .as_bytes() + .to_vec(); + let stored = state + .db + .get_event_by_id(community, &reply_id_bytes) + .await + .expect("query reply") + .expect("reply persisted"); + let marker = |m: &str| -> Option { + stored.event.tags.iter().find_map(|t| { + let p = t.as_slice(); + if p.len() >= 4 && p[0] == "e" && p[3] == m { + Some(p[1].clone()) + } else { + None + } + }) + }; + assert_eq!( + marker("reply").as_deref(), + Some(root_hex.as_str()), + "direct reply emits a single reply marker at the root" + ); + assert_eq!( + marker("root"), + None, + "direct reply omits the root marker (matches SDK thread_tags)" + ); + + // Thread metadata reflects a depth-1 reply parented on the root. + let meta = state + .db + .get_thread_metadata_by_event(community, &reply_id_bytes) + .await + .expect("query meta") + .expect("reply has thread metadata"); + assert_eq!( + meta.depth, 1, + "direct reply to a top-level message is depth 1" + ); + let root_bytes = nostr::EventId::from_hex(&root_hex) + .expect("root id") + .as_bytes() + .to_vec(); + assert_eq!(meta.parent_event_id.as_deref(), Some(root_bytes.as_slice())); + assert_eq!(meta.root_event_id.as_deref(), Some(root_bytes.as_slice())); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_replies_recover_metadata_less_parent_ancestry() { + // A parent that carries NIP-10 root/reply markers but has NO + // thread_metadata row (legacy or not-yet-indexed) must be recognized as + // nested: the workflow reply threads at depth 2 onto the parent's own + // root, not a false top-level depth 1. + let state = test_state().await; + + let author = nostr::Keys::generate(); + let author_hex = author.public_key().to_hex(); + + let host = format!("wf-legacy-{}.example", uuid::Uuid::new_v4().simple()); + let community = match state + .db + .create_community_with_owner(&host, &author_hex) + .await + .expect("create community") + { + CreateCommunityWithOwnerResult::Created(rec) => rec.id, + other => panic!("expected fresh community, got {other:?}"), + }; + + let channel = state + .db + .create_channel( + community, + "wf-legacy", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &author.public_key().to_bytes(), + None, + ) + .await + .expect("create channel"); + + let channel_hex = channel.id.to_string(); + + // A top-level root message, inserted WITHOUT any thread metadata row. + let root_event = EventBuilder::new(Kind::from(KIND_STREAM_MESSAGE as u16), "root") + .tags([Tag::parse(["h", &channel_hex]).expect("h tag")]) + .sign_with_keys(&author) + .expect("sign root"); + let root_hex = root_event.id.to_hex(); + state + .db + .insert_event(community, &root_event, Some(channel.id)) + .await + .expect("insert root"); + + // A nested parent that marks its root/reply — but, crucially, is stored + // with NO thread_metadata row (the legacy/unindexed case F1 addresses). + let parent_event = + EventBuilder::new(Kind::from(KIND_STREAM_MESSAGE as u16), "nested parent") + .tags([ + Tag::parse(["h", &channel_hex]).expect("h tag"), + Tag::parse(["e", &root_hex, "", "root"]).expect("root tag"), + Tag::parse(["e", &root_hex, "", "reply"]).expect("reply tag"), + ]) + .sign_with_keys(&author) + .expect("sign parent"); + let parent_hex = parent_event.id.to_hex(); + state + .db + .insert_event(community, &parent_event, Some(channel.id)) + .await + .expect("insert parent"); + assert!( + state + .db + .get_thread_metadata_by_event(community, parent_event.id.as_bytes()) + .await + .expect("query parent meta") + .is_none(), + "test premise: the nested parent must have no thread_metadata row" + ); + + // A workflow reply onto the metadata-less nested parent. + let reply_hex = RelayActionSink::new(&state) + .send_message( + community, + &channel_hex, + "workflow reply", + &author_hex, + Some(&parent_hex), + ) + .await + .expect("send reply"); + + let reply_id_bytes = nostr::EventId::from_hex(&reply_hex) + .expect("reply id") + .as_bytes() + .to_vec(); + let meta = state + .db + .get_thread_metadata_by_event(community, &reply_id_bytes) + .await + .expect("query meta") + .expect("reply has thread metadata"); + + assert_eq!( + meta.depth, 2, + "reply to a marked-but-unindexed nested parent is depth 2, not top-level" + ); + let root_bytes = nostr::EventId::from_hex(&root_hex) + .expect("root id") + .as_bytes() + .to_vec(); + let parent_bytes = parent_event.id.as_bytes().to_vec(); + assert_eq!( + meta.root_event_id.as_deref(), + Some(root_bytes.as_slice()), + "root recovered from the parent's own NIP-10 markers" + ); + assert_eq!( + meta.parent_event_id.as_deref(), + Some(parent_bytes.as_slice()) + ); + + // The reply's own NIP-10 e-tags point root→the recovered root, + // reply→the immediate parent (matching the ingest resolver). + let stored = state + .db + .get_event_by_id(community, &reply_id_bytes) + .await + .expect("query reply") + .expect("reply persisted"); + let marker = |m: &str| -> Option { + stored.event.tags.iter().find_map(|t| { + let p = t.as_slice(); + if p.len() >= 4 && p[0] == "e" && p[3] == m { + Some(p[1].clone()) + } else { + None + } + }) + }; + assert_eq!(marker("root").as_deref(), Some(root_hex.as_str())); + assert_eq!(marker("reply").as_deref(), Some(parent_hex.as_str())); + + // A root-only parent is top-level under the shared collapse rule, even + // without metadata. A workflow reply therefore starts a thread at P, + // rather than incorrectly inheriting the marker's unrelated root R. + let root_only_parent = + EventBuilder::new(Kind::from(KIND_STREAM_MESSAGE as u16), "root-only parent") + .tags([ + Tag::parse(["h", &channel_hex]).expect("h tag"), + Tag::parse(["e", &root_hex, "", "root"]).expect("root tag"), + ]) + .sign_with_keys(&author) + .expect("sign root-only parent"); + let root_only_parent_hex = root_only_parent.id.to_hex(); + let root_only_parent_bytes = root_only_parent.id.as_bytes().to_vec(); + state + .db + .insert_event(community, &root_only_parent, Some(channel.id)) + .await + .expect("insert root-only parent"); + + let root_only_reply_hex = RelayActionSink::new(&state) + .send_message( + community, + &channel_hex, + "workflow reply to root-only parent", + &author_hex, + Some(&root_only_parent_hex), + ) + .await + .expect("send root-only reply"); + let root_only_reply_bytes = nostr::EventId::from_hex(&root_only_reply_hex) + .expect("reply id") + .as_bytes() + .to_vec(); + let root_only_meta = state + .db + .get_thread_metadata_by_event(community, &root_only_reply_bytes) + .await + .expect("query root-only reply meta") + .expect("root-only reply has thread metadata"); + assert_eq!(root_only_meta.depth, 1); + assert_eq!( + root_only_meta.parent_event_id.as_deref(), + Some(root_only_parent_bytes.as_slice()) + ); + assert_eq!( + root_only_meta.root_event_id.as_deref(), + Some(root_only_parent_bytes.as_slice()) + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_reply_to_missing_parent_errors() { + let state = test_state().await; + let author = nostr::Keys::generate(); + let author_hex = author.public_key().to_hex(); + let host = format!("wf-missing-{}.example", uuid::Uuid::new_v4().simple()); + let community = match state + .db + .create_community_with_owner(&host, &author_hex) + .await + .expect("create community") + { + CreateCommunityWithOwnerResult::Created(rec) => rec.id, + other => panic!("expected fresh community, got {other:?}"), + }; + let channel = state + .db + .create_channel( + community, + "wf-missing", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &author.public_key().to_bytes(), + None, + ) + .await + .expect("create channel"); + + let unknown = nostr::Keys::generate().public_key().to_hex(); + let err = RelayActionSink::new(&state) + .send_message( + community, + &channel.id.to_string(), + "orphan reply", + &author_hex, + Some(&unknown), + ) + .await + .expect_err("reply to a non-existent parent must fail"); + assert!( + matches!(err, ActionSinkError::InvalidInput(_)), + "expected InvalidInput, got {err:?}" + ); + } } diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 882cbabfe2..b119d26774 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -2661,6 +2661,112 @@ async fn test_reply_ingest_pushes_live_thread_summary() { client.disconnect().await.expect("disconnect"); } +/// F3 (workflow path): a `message_posted` workflow whose `send_message` action +/// has `reply_in_thread: true` posts a threaded reply to the triggering +/// top-level message — and that relay-built reply must push the same live +/// kind:39005 thread-summary overlay the human ingest path does, so desktops +/// update the root's badge without refetching. Also exercises F2's semantics: +/// the `trigger_is_reply == false` filter must fire on the top-level message. +#[tokio::test] +#[ignore] +async fn test_workflow_reply_in_thread_pushes_live_thread_summary() { + let url = relay_url(); + let http = relay_http_url(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + let channel = create_test_channel(&keys).await; + + // A message_posted workflow that replies in-thread, but only to NEW + // top-level messages (`trigger_is_reply == false`) — so it cannot recurse + // on the reply it just posted. + let yaml = "name: reply-bot\n\ + description: F3 live probe\n\ + trigger:\n\ + \x20 on: message_posted\n\ + \x20 filter: \"trigger_is_reply == false\"\n\ + steps:\n\ + \x20 - id: step1\n\ + \x20 name: Reply\n\ + \x20 action: send_message\n\ + \x20 text: \"auto-reply\"\n\ + \x20 reply_in_thread: true\n" + .to_string(); + let def = EventBuilder::new(Kind::Custom(30620), yaml) + .tags([ + Tag::parse(["d", &Uuid::new_v4().to_string()]).unwrap(), + Tag::parse(["h", channel.as_str()]).unwrap(), + Tag::parse(["name", "reply-bot"]).unwrap(), + ]) + .sign_with_keys(&keys) + .expect("sign workflow def"); + let client = reqwest::Client::new(); + let resp = client + .post(format!("{http}/events")) + .header("X-Pubkey", &pubkey_hex) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&def).unwrap()) + .send() + .await + .expect("submit workflow def"); + let body: serde_json::Value = resp.json().await.expect("parse def response"); + assert!( + body["accepted"].as_bool().unwrap_or(false), + "workflow def not accepted: {body}" + ); + + // Live 39005 subscription for the channel, shaped like the desktop window + // store's. + let mut ws = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let sid = sub_id("wf-live-summary"); + let filter = Filter::new() + .kind(Kind::Custom(39005)) + .custom_tags(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]); + ws.subscribe(&sid, vec![filter]).await.expect("subscribe"); + ws.collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("EOSE"); + + // Post a top-level message — the workflow fires and posts a threaded reply. + let root = EventBuilder::new(Kind::Custom(9), "trigger me") + .tags([Tag::parse(["h", channel.as_str()]).unwrap()]) + .sign_with_keys(&keys) + .expect("sign root"); + let root_id = root.id; + let ok = ws.send_event(root).await.expect("send root"); + assert!(ok.accepted, "root rejected: {}", ok.message); + + // The workflow reply's 39005 overlay must arrive and target the root with a + // reply_count of 1 — proving the relay-built reply pushed the live summary. + let summary = loop { + match ws + .recv_event(Duration::from_secs(10)) + .await + .expect("recv 39005 for workflow reply") + { + RelayMessage::Event { event, .. } if event.kind == Kind::Custom(39005) => break *event, + _ => continue, + } + }; + let root_tag_val = summary + .tags + .iter() + .find(|t| t.as_slice().first().map(String::as_str) == Some("e")) + .and_then(|t| t.content().map(str::to_string)) + .expect("summary carries root e-tag"); + assert_eq!( + root_tag_val, + root_id.to_hex(), + "workflow-reply summary targets the triggering top-level message as root" + ); + let content: serde_json::Value = serde_json::from_str(&summary.content).expect("JSON"); + assert_eq!( + content["reply_count"], 1, + "workflow threaded reply counted up: {content}" + ); + + ws.disconnect().await.expect("disconnect"); +} + /// Read a member's authoritative role from the relay-signed kind:39002 member /// list. The relay's own view of membership, not the client's — a kind:9000 can /// be `accepted` (stored) while its membership side effect fails, so asserting diff --git a/crates/buzz-workflow/src/action_sink.rs b/crates/buzz-workflow/src/action_sink.rs index 0c6002e74e..079c27a913 100644 --- a/crates/buzz-workflow/src/action_sink.rs +++ b/crates/buzz-workflow/src/action_sink.rs @@ -57,6 +57,9 @@ pub trait ActionSink: Send + Sync { /// - `text`: message body (must not be empty/whitespace-only) /// - `author_pubkey`: hex-encoded pubkey of the workflow owner (used for /// the `p` attribution tag; the relay keypair signs the event) + /// - `reply_to`: when `Some(event_id_hex)`, the message is posted as a + /// threaded reply to that event (NIP-10 root/reply tags + real thread + /// metadata); when `None`, it is a top-level channel message. /// /// Returns the event ID hex string on success. fn send_message( @@ -65,5 +68,6 @@ pub trait ActionSink: Send + Sync { channel_id: &str, text: &str, author_pubkey: &str, + reply_to: Option<&str>, ) -> Pin> + Send + '_>>; } diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index dffa492716..5c712dcff7 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -37,6 +37,10 @@ pub struct TriggerContext { pub emoji: String, /// Event ID of the triggering message (hex string). pub message_id: String, + /// True when the triggering event is itself a threaded reply (carries a + /// NIP-10 `reply`/`root` marker e-tag). Lets a `message_posted` filter + /// select only top-level messages via `trigger_is_reply == false`. + pub is_reply: bool, /// Arbitrary webhook body fields (webhook trigger). pub webhook_fields: HashMap, } @@ -213,6 +217,7 @@ fn apply_filter(value: String, filter: &str) -> Result { /// | `trigger.timestamp` | `trigger_timestamp` | /// | `trigger.emoji` | `trigger_emoji` | /// | `trigger.message_id` | `trigger_message_id` | +/// | `trigger.is_reply` | `trigger_is_reply` (bool) | /// | `steps.STEP_ID.output.FIELD` | `steps_STEP_ID_output_FIELD` | /// /// Also registers string helper functions that the `cron` crate's `evalexpr` v11 @@ -300,6 +305,14 @@ pub fn build_eval_context( .map_err(|e| WorkflowError::ConditionError(e.to_string()))?; } + // `trigger_is_reply` is boolean (not a string field), so a filter can read + // `trigger_is_reply == false` to fire only on top-level messages. + ctx.set_value( + "trigger_is_reply".into(), + Value::Boolean(trigger_ctx.is_reply), + ) + .map_err(|e| WorkflowError::ConditionError(e.to_string()))?; + for (step_id, output) in step_outputs { if let JsonValue::Object(map) = output { for (field, val) in map { @@ -403,9 +416,14 @@ pub fn resolve_step_templates( }; match &step.action { - SendMessage { text, channel } => Ok(SendMessage { + SendMessage { + text, + channel, + reply_in_thread, + } => Ok(SendMessage { text: t(text)?, channel: t_opt(channel)?, + reply_in_thread: *reply_in_thread, }), SendDm { to, text } => Ok(SendDm { to: t(to)?, @@ -546,7 +564,11 @@ pub async fn dispatch_action( let result = serving_write .protect(async { match action { - SendMessage { text, channel } => { + SendMessage { + text, + channel, + reply_in_thread, + } => { // Look up workflow metadata for destination validation and // attribution, scoped to the run's community — the same run/workflow // UUID may exist in another community, so a bare-id lookup could @@ -577,16 +599,38 @@ pub async fn dispatch_action( )?; let owner_pubkey_hex = hex::encode(&workflow.owner_pubkey); + // Thread the reply onto the triggering message when requested. + // The trigger must carry the event to reply to; schema + // validation already forbids `reply_in_thread` on triggers + // that have no message, so an empty id here is a real fault. + let reply_to = if *reply_in_thread { + if trigger_ctx.message_id.is_empty() { + return Err(WorkflowError::InvalidDefinition( + "SendMessage: reply_in_thread is set but the trigger has no message_id to reply to".into(), + )); + } + Some(trigger_ctx.message_id.as_str()) + } else { + None + }; + info!( run_id = %run_id, step = step_id, channel = %channel_id, + reply_in_thread = *reply_in_thread, "SendMessage → {channel_id}: {text}" ); let event_id = engine .action_sink()? - .send_message(community_id, &channel_id, text, &owner_pubkey_hex) + .send_message( + community_id, + &channel_id, + text, + &owner_pubkey_hex, + reply_to, + ) .await .map_err(WorkflowError::from)?; @@ -1266,6 +1310,7 @@ mod tests { timestamp: "1700000000".to_owned(), emoji: "fire".to_owned(), message_id: "event-id-hex".to_owned(), + is_reply: false, webhook_fields: HashMap::new(), } } @@ -1385,6 +1430,56 @@ mod tests { assert!(!result); } + #[tokio::test] + async fn condition_trigger_is_reply_selects_top_level_only() { + // The top-level-only filter from the feature's use case. + let mut ctx = make_trigger(); + + ctx.is_reply = false; + assert!( + evaluate_condition("trigger_is_reply == false", &ctx, &HashMap::new()) + .await + .unwrap(), + "top-level message should pass the filter" + ); + + ctx.is_reply = true; + assert!( + !evaluate_condition("trigger_is_reply == false", &ctx, &HashMap::new()) + .await + .unwrap(), + "threaded reply should be filtered out" + ); + } + + #[test] + fn resolve_step_templates_carries_reply_in_thread() { + let ctx = make_trigger(); + let step = Step { + id: "reply".to_owned(), + name: None, + if_expr: None, + timeout_secs: None, + action: ActionDef::SendMessage { + text: "hi {{trigger.author}}".to_owned(), + channel: None, + reply_in_thread: true, + }, + }; + let resolved = resolve_step_templates(&step, &ctx, &HashMap::new()).unwrap(); + match resolved { + ActionDef::SendMessage { + text, + reply_in_thread, + .. + } => { + assert_eq!(text, "hi abc123def456"); + assert!(reply_in_thread, "reply_in_thread must survive resolution"); + } + other => panic!("unexpected action: {other:?}"), + } + } + #[tokio::test] async fn condition_or_expression() { let ctx = make_trigger(); // text contains "P1" diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index 5c17b34e10..bceb6d8bd8 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -997,10 +997,24 @@ pub fn build_trigger_context(event: &buzz_core::StoredEvent) -> executor::Trigge timestamp: event.event.created_at.as_secs().to_string(), emoji, message_id, + is_reply: event_is_reply(&event.event), webhook_fields: HashMap::new(), } } +/// True when an event is a threaded reply — it carries a valid NIP-10 `reply` +/// marker. Delegates to the shared [`buzz_core::nip10`] parser so this stays in +/// lockstep with ingest's `resolve_nip10_thread_meta`: a `root` marker alone is +/// top-level, and a marker with a malformed (non-64-hex) event id is ignored by +/// ingest, so it must not flip `trigger_is_reply` either — else a +/// `trigger_is_reply == false` workflow would skip a message ingest stored as a +/// new top-level post. +fn event_is_reply(event: &nostr::Event) -> bool { + buzz_core::nip10::parse_thread_markers(&event.tags) + .reply + .is_some() +} + /// Pure authority decision for [`WorkflowEngine::check_owner_authority`]. /// /// `role` is the owner's *current* active role in the workflow's channel @@ -1588,6 +1602,144 @@ steps: // Non-reaction events have empty emoji. assert_eq!(ctx.emoji, ""); assert!(ctx.webhook_fields.is_empty()); + // A top-level message (no e-tags) is not a reply. + assert!(!ctx.is_reply); + } + + #[test] + fn build_trigger_context_is_reply_true_for_threaded_message() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + use uuid::Uuid; + let root = Keys::generate(); + let root_event = EventBuilder::new(Kind::Custom(9), "root") + .tags([]) + .sign_with_keys(&root) + .expect("sign root"); + let root_hex = root_event.id.to_hex(); + + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "a threaded reply") + .tags([ + Tag::parse(["e", &root_hex, "", "root"]).expect("root tag"), + Tag::parse(["e", &root_hex, "", "reply"]).expect("reply tag"), + ]) + .sign_with_keys(&keys) + .expect("sign"); + let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4())); + let ctx = build_trigger_context(&stored); + assert!(ctx.is_reply, "message with reply/root e-tags is a reply"); + } + + #[test] + fn build_trigger_context_is_reply_true_for_reply_only_marker() { + // A NIP-10 `reply` marker without a `root` marker (the fallback ingest + // treats as `root == reply`) is still a threaded reply. + use nostr::{EventBuilder, Keys, Kind, Tag}; + use uuid::Uuid; + let parent = Keys::generate(); + let parent_event = EventBuilder::new(Kind::Custom(9), "parent") + .sign_with_keys(&parent) + .expect("sign parent"); + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "reply only") + .tags([Tag::parse(["e", &parent_event.id.to_hex(), "", "reply"]).expect("reply tag")]) + .sign_with_keys(&keys) + .expect("sign"); + let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4())); + let ctx = build_trigger_context(&stored); + assert!(ctx.is_reply, "a lone `reply` marker is a reply"); + } + + #[test] + fn build_trigger_context_is_reply_false_for_root_only_marker() { + // Ingest treats `(root=Some, reply=None)` as top-level, so + // `event_is_reply` must too — otherwise `trigger_is_reply == false` + // would skip a message the relay stored as a new top-level post. + use nostr::{EventBuilder, Keys, Kind, Tag}; + use uuid::Uuid; + let root = Keys::generate(); + let root_event = EventBuilder::new(Kind::Custom(9), "root") + .sign_with_keys(&root) + .expect("sign root"); + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "root marker only") + .tags([Tag::parse(["e", &root_event.id.to_hex(), "", "root"]).expect("root tag")]) + .sign_with_keys(&keys) + .expect("sign"); + let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4())); + let ctx = build_trigger_context(&stored); + assert!( + !ctx.is_reply, + "a lone `root` marker is top-level to ingest, not a reply" + ); + } + + #[test] + fn build_trigger_context_is_reply_false_for_unmarked_e_tag() { + // A bare `e` tag with no NIP-10 marker (e.g. a plain mention/quote) is + // not treated as a thread reply — only `reply`/`root` markers count. + use nostr::{EventBuilder, Keys, Kind, Tag}; + use uuid::Uuid; + let other = Keys::generate(); + let other_event = EventBuilder::new(Kind::Custom(9), "other") + .tags([]) + .sign_with_keys(&other) + .expect("sign"); + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "quotes another") + .tags([Tag::parse(["e", &other_event.id.to_hex()]).expect("bare e tag")]) + .sign_with_keys(&keys) + .expect("sign"); + let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4())); + let ctx = build_trigger_context(&stored); + assert!(!ctx.is_reply, "unmarked e-tag must not count as a reply"); + } + + #[test] + fn build_trigger_context_is_reply_false_for_malformed_reply_id() { + // Ingest gates a marker on a valid 64-hex event id; a malformed reply + // id is not a thread link, so ingest stores the event top-level. The + // predicate must agree, or `trigger_is_reply == false` would skip it. + use nostr::{EventBuilder, Keys, Kind, Tag}; + use uuid::Uuid; + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "malformed reply marker") + .tags([Tag::parse(["e", "bad", "", "reply"]).expect("reply tag")]) + .sign_with_keys(&keys) + .expect("sign"); + let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4())); + let ctx = build_trigger_context(&stored); + assert!( + !ctx.is_reply, + "a malformed reply id is ignored by ingest, so it is top-level" + ); + } + + #[test] + fn build_trigger_context_is_reply_false_for_valid_root_malformed_reply() { + // A valid `root` marker but a malformed `reply` id: ingest ignores the + // reply and stores the event as root-only, i.e. top-level. The predicate + // must not flip to reply on the malformed marker. + use nostr::{EventBuilder, Keys, Kind, Tag}; + use uuid::Uuid; + let root = Keys::generate(); + let root_event = EventBuilder::new(Kind::Custom(9), "root") + .sign_with_keys(&root) + .expect("sign root"); + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "valid root, malformed reply") + .tags([ + Tag::parse(["e", &root_event.id.to_hex(), "", "root"]).expect("root tag"), + Tag::parse(["e", "bad", "", "reply"]).expect("reply tag"), + ]) + .sign_with_keys(&keys) + .expect("sign"); + let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4())); + let ctx = build_trigger_context(&stored); + assert!( + !ctx.is_reply, + "a valid root with a malformed reply id is top-level to ingest" + ); } #[test] diff --git a/crates/buzz-workflow/src/schema.rs b/crates/buzz-workflow/src/schema.rs index afee730d60..0e8dfdb52e 100644 --- a/crates/buzz-workflow/src/schema.rs +++ b/crates/buzz-workflow/src/schema.rs @@ -100,6 +100,11 @@ pub enum ActionDef { /// Optional channel UUID override. Must be a valid UUID string. #[serde(default)] channel: Option, + /// Reply to the triggering message in its thread instead of posting a + /// new top-level message. Only valid for message-based triggers, which + /// carry a triggering event to reply to. + #[serde(default)] + reply_in_thread: bool, }, /// Send a direct message to a user. SendDm { @@ -208,6 +213,34 @@ impl WorkflowDef { } } + // `reply_in_thread` requires a triggering message to reply to. Schedule + // and webhook triggers have none, so reject the combination at + // definition time rather than failing silently at run time. + let trigger_has_message = matches!( + self.trigger, + TriggerDef::MessagePosted { .. } + | TriggerDef::ReactionAdded { .. } + | TriggerDef::DiffPosted { .. } + ); + if !trigger_has_message { + for step in &self.steps { + if matches!( + step.action, + ActionDef::SendMessage { + reply_in_thread: true, + .. + } + ) { + return Err(WorkflowError::InvalidDefinition(format!( + "step '{}': reply_in_thread requires a message-based trigger \ + (message_posted, reaction_added, or diff_posted); \ + schedule and webhook triggers have no message to reply to", + step.id + ))); + } + } + } + if let TriggerDef::Schedule { cron, interval } = &self.trigger { if cron.is_none() && interval.is_none() { return Err(WorkflowError::InvalidDefinition( @@ -458,6 +491,78 @@ mod tests { assert!(matches!(err, WorkflowError::InvalidDefinition(_))); } + #[test] + fn reply_in_thread_defaults_false_and_round_trips() { + // Absent field defaults to false. + let yaml = "name: Auto Reply\ntrigger:\n on: message_posted\nsteps:\n - id: s1\n action: send_message\n text: hi\n"; + let (def, _) = parse_yaml(yaml).expect("parse failed"); + match &def.steps[0].action { + ActionDef::SendMessage { + reply_in_thread, .. + } => assert!(!reply_in_thread, "should default to false"), + other => panic!("unexpected action: {other:?}"), + } + + // Explicit true parses, and survives a JSON round-trip. + let yaml = "name: Auto Reply\ntrigger:\n on: message_posted\nsteps:\n - id: s1\n action: send_message\n text: hi\n reply_in_thread: true\n"; + let (def, _) = parse_yaml(yaml).expect("parse failed"); + match &def.steps[0].action { + ActionDef::SendMessage { + reply_in_thread, .. + } => assert!(reply_in_thread), + other => panic!("unexpected action: {other:?}"), + } + let json = serde_json::to_string(&def).expect("serialize"); + let reparsed: WorkflowDef = serde_json::from_str(&json).expect("json round-trip"); + assert!(matches!( + &reparsed.steps[0].action, + ActionDef::SendMessage { + reply_in_thread: true, + .. + } + )); + } + + #[test] + fn validate_accepts_reply_in_thread_on_message_triggers() { + for on in ["message_posted", "reaction_added", "diff_posted"] { + let yaml = format!( + "name: Auto Reply\ntrigger:\n on: {on}\nsteps:\n - id: s1\n action: send_message\n text: hi\n reply_in_thread: true\n" + ); + parse_yaml(&yaml) + .unwrap_or_else(|e| panic!("reply_in_thread should be valid on {on}: {e}")); + } + } + + #[test] + fn validate_rejects_reply_in_thread_on_schedule_trigger() { + let yaml = "name: Bad\ntrigger:\n on: schedule\n cron: '0 9 * * 1-5'\nsteps:\n - id: s1\n action: send_message\n text: hi\n reply_in_thread: true\n"; + let err = parse_yaml(yaml).unwrap_err(); + match &err { + WorkflowError::InvalidDefinition(msg) => { + assert!( + msg.contains("reply_in_thread"), + "expected reply_in_thread in: {msg}" + ); + } + other => panic!("expected InvalidDefinition, got: {other}"), + } + } + + #[test] + fn validate_rejects_reply_in_thread_on_webhook_trigger() { + let yaml = "name: Bad\ntrigger:\n on: webhook\nsteps:\n - id: s1\n action: send_message\n text: hi\n channel: 00000000-0000-0000-0000-000000000000\n reply_in_thread: true\n"; + let err = parse_yaml(yaml).unwrap_err(); + assert!(matches!(err, WorkflowError::InvalidDefinition(_))); + } + + #[test] + fn validate_allows_reply_in_thread_false_on_schedule() { + // Explicit `false` on a schedule trigger is fine — no message needed. + let yaml = "name: OK\ntrigger:\n on: schedule\n cron: '0 9 * * 1-5'\nsteps:\n - id: s1\n action: send_message\n text: hi\n reply_in_thread: false\n"; + parse_yaml(yaml).expect("reply_in_thread: false on schedule should be valid"); + } + #[test] fn enabled_defaults_to_true() { let yaml = "name: Test\ntrigger:\n on: webhook\nsteps:\n - id: s1\n action: delay\n duration: 1m\n"; diff --git a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx index bf96ddfd74..9a525591eb 100644 --- a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx @@ -39,6 +39,7 @@ import { formStateToYaml, nextStepId, supportsMessageTextCondition, + withTriggerType, yamlToFormState, } from "./workflowFormTypes"; import { defaultScheduleTrigger } from "./workflowSchedule"; @@ -744,23 +745,27 @@ export const WorkflowFormBuilder = React.forwardRef< ariaLabel="Trigger event" disabled={disabled} labels={TRIGGER_LABELS} - onChange={(triggerType) => + onChange={(triggerType) => { + const next = withTriggerType( + formState, + triggerType, + ); updateFormState({ - ...formState, + ...next, steps: supportsMessageTextCondition( triggerType, ) - ? formState.steps - : formState.steps.map((step) => ({ + ? next.steps + : next.steps.map((step) => ({ ...step, condition: undefined, })), trigger: triggerType === "schedule" ? defaultScheduleTrigger() - : { on: triggerType }, - }) - } + : next.trigger, + }); + }} options={SELECTABLE_TRIGGER_TYPES} value={formState.trigger.on} /> diff --git a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx index d6b55b210f..20c6d4dad3 100644 --- a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx +++ b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx @@ -3,6 +3,7 @@ import { type ReactNode, useState } from "react"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { Checkbox } from "@/shared/ui/checkbox"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; import { WorkflowDurationField } from "./WorkflowDurationField"; @@ -10,6 +11,7 @@ import { WorkflowMessageTextCondition } from "./WorkflowMessageTextConditionEdit import { FieldLabel, FormSelect } from "./workflowFormPrimitives"; import { WorkflowWebhookHeadersEditor } from "./WorkflowWebhookHeadersEditor"; import { + isThreadReplyEligibleTrigger, supportsMessageTextCondition, type StepFormState, type TriggerType, @@ -183,6 +185,21 @@ function StepConfigFields({ ) : null} )} + {isThreadReplyEligibleTrigger(triggerType) ? ( +
+ + onUpdate({ ...step, replyInThread: checked === true }) + } + /> + +
+ ) : null} ); case "send_dm": diff --git a/desktop/src/features/workflows/ui/workflowFormTypes.test.mjs b/desktop/src/features/workflows/ui/workflowFormTypes.test.mjs index 2615d09681..958b276420 100644 --- a/desktop/src/features/workflows/ui/workflowFormTypes.test.mjs +++ b/desktop/src/features/workflows/ui/workflowFormTypes.test.mjs @@ -4,8 +4,11 @@ import { parse as parseYaml } from "yaml"; import { formStateToYaml, + isThreadReplyEligibleTrigger, supportsMessageTextCondition, + withTriggerType, yamlToFormState, + DEFAULT_FORM_STATE, } from "./workflowFormTypes.ts"; function accepted(yaml) { @@ -25,6 +28,22 @@ function normalizeBackendDefaults(value) { return copy; } +function sendMessageState(overrides) { + return { + ...DEFAULT_FORM_STATE, + name: "Auto Reply", + trigger: { on: "message_posted", filter: "trigger_is_reply == false" }, + steps: [ + { + id: "step_1", + action: "send_message", + text: "pre-written reply", + ...overrides, + }, + ], + }; +} + test("message-text conditions are limited to message-bearing triggers", () => { assert.equal(supportsMessageTextCondition("message_posted"), true); assert.equal(supportsMessageTextCondition("diff_posted"), true); @@ -196,3 +215,103 @@ test("values the Form serializer would normalize are refused", () => { for (const yaml of fixtures) assert.equal(yamlToFormState(yaml).ok, false); }); + +test("reply_in_thread is emitted only when the checkbox is on", () => { + const withReply = formStateToYaml(sendMessageState({ replyInThread: true })); + assert.match(withReply, /reply_in_thread: true/); + + const withoutReply = formStateToYaml( + sendMessageState({ replyInThread: false }), + ); + assert.doesNotMatch(withoutReply, /reply_in_thread/); + + const unset = formStateToYaml(sendMessageState({})); + assert.doesNotMatch(unset, /reply_in_thread/); +}); + +test("switching from Message Posted clears reply_in_thread before save", () => { + const messagePosted = sendMessageState({ replyInThread: true }); + + for (const triggerType of ["schedule", "webhook"]) { + const switched = withTriggerType(messagePosted, triggerType); + assert.equal(switched.trigger.on, triggerType); + assert.equal(switched.steps[0].replyInThread, false); + assert.doesNotMatch(formStateToYaml(switched), /reply_in_thread/); + } +}); + +test("an ineligible trigger cannot resurrect reply_in_thread through an action change", () => { + // Full repro: Message Posted → Send Message → enable Reply → switch action to + // Delay → switch trigger to an ineligible one → switch action back to Send + // Message. The action picker changes only `action` (a plain spread, mirrored + // here), so `withTriggerType` must clear the hidden flag on every step, not + // just the ones whose current action is send_message. + for (const triggerType of ["schedule", "webhook"]) { + const enabled = sendMessageState({ replyInThread: true }); + const asDelay = { + ...enabled, + steps: [{ ...enabled.steps[0], action: "delay", duration: "5m" }], + }; + const switched = withTriggerType(asDelay, triggerType); + const backToSend = { + ...switched, + steps: [{ ...switched.steps[0], action: "send_message" }], + }; + + assert.equal(backToSend.steps[0].replyInThread, false, triggerType); + assert.doesNotMatch(formStateToYaml(backToSend), /reply_in_thread/); + } +}); + +test("invalid reply_in_thread values are refused rather than normalized", () => { + const original = (yaml) => { + const result = yamlToFormState(yaml); + assert.equal(result.ok, false); + assert.match(result.error, /YAML editor/); + return result; + }; + + // Non-boolean would be silently deleted on serialization. + const nonBoolean = `name: Coerced\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: send_message, text: hi, reply_in_thread: "yes" }]\n`; + assert.match(original(nonBoolean).error, /reply_in_thread must be a boolean/); + + // true under an ineligible trigger would round-trip a backend-invalid definition. + for (const trigger of ["schedule, cron: '0 9 * * *'", "webhook"]) { + const yaml = `name: Ineligible\ntrigger: { on: ${trigger} }\nsteps: [{ id: s1, action: send_message, text: hi, reply_in_thread: true }]\n`; + assert.match( + original(yaml).error, + /reply_in_thread is not supported for (schedule|webhook) triggers/, + ); + } +}); + +test("reply_in_thread eligibility follows trigger capability", () => { + assert.equal(isThreadReplyEligibleTrigger("message_posted"), true); + assert.equal(isThreadReplyEligibleTrigger("schedule"), false); + assert.equal(isThreadReplyEligibleTrigger("webhook"), false); +}); +test("reply_in_thread round-trips YAML -> form -> YAML", () => { + const yaml = formStateToYaml(sendMessageState({ replyInThread: true })); + const parsed = yamlToFormState(yaml); + assert.equal(parsed.ok, true); + assert.equal(parsed.state.steps[0].replyInThread, true); + + const reserialized = formStateToYaml(parsed.state); + assert.match(reserialized, /reply_in_thread: true/); +}); + +test("absent reply_in_thread parses as false", () => { + const yaml = [ + "name: No Reply", + "trigger:", + " on: message_posted", + "steps:", + " - id: step_1", + " action: send_message", + " text: hi", + "", + ].join("\n"); + const parsed = yamlToFormState(yaml); + assert.equal(parsed.ok, true); + assert.equal(parsed.state.steps[0].replyInThread, false); +}); diff --git a/desktop/src/features/workflows/ui/workflowFormTypes.ts b/desktop/src/features/workflows/ui/workflowFormTypes.ts index 2feae37ac8..345f7c7134 100644 --- a/desktop/src/features/workflows/ui/workflowFormTypes.ts +++ b/desktop/src/features/workflows/ui/workflowFormTypes.ts @@ -69,6 +69,7 @@ export type StepFormState = { duration?: string; text?: string; channel?: string; + replyInThread?: boolean; to?: string; url?: string; method?: string; @@ -165,6 +166,7 @@ function actionFieldsForStep(step: StepFormState): Record { case "send_message": if (step.text) fields.text = step.text; if (step.channel) fields.channel = step.channel; + if (step.replyInThread) fields.reply_in_thread = true; break; case "send_dm": if (step.to) fields.to = step.to; @@ -194,6 +196,28 @@ function actionFieldsForStep(step: StepFormState): Record { return fields; } +export function isThreadReplyEligibleTrigger(trigger: TriggerType): boolean { + return trigger !== "webhook" && trigger !== "schedule"; +} + +export function withTriggerType( + state: WorkflowFormState, + triggerType: TriggerType, +): WorkflowFormState { + return { + ...state, + trigger: { on: triggerType }, + // Clear threaded-reply state on every step, not just send_message ones: + // a hidden `replyInThread` on a step whose action was changed away from + // send_message would otherwise resurrect when the action is switched back. + steps: isThreadReplyEligibleTrigger(triggerType) + ? state.steps + : state.steps.map((step) => + step.replyInThread ? { ...step, replyInThread: false } : step, + ), + }; +} + export function formStateToYaml(state: WorkflowFormState): string { const trigger: Record = { on: state.trigger.on }; if ( @@ -268,7 +292,12 @@ const TRIGGER_KEYS: Record> = { const COMMON_STEP_KEYS = ["id", "name", "action", "if", "timeout_secs"]; const ACTION_STEP_KEYS: Record> = { delay: new Set([...COMMON_STEP_KEYS, "duration"]), - send_message: new Set([...COMMON_STEP_KEYS, "text", "channel"]), + send_message: new Set([ + ...COMMON_STEP_KEYS, + "text", + "channel", + "reply_in_thread", + ]), send_dm: new Set([...COMMON_STEP_KEYS, "to", "text"]), call_webhook: new Set([ ...COMMON_STEP_KEYS, @@ -568,6 +597,21 @@ export function yamlToFormState( } } + if (step.reply_in_thread !== undefined) { + if (typeof step.reply_in_thread !== "boolean") { + return { + ok: false, + error: `Step ${number} reply_in_thread must be a boolean — use the YAML editor`, + }; + } + if (step.reply_in_thread && !isThreadReplyEligibleTrigger(triggerOn)) { + return { + ok: false, + error: `reply_in_thread is not supported for ${triggerOn} triggers — use the YAML editor`, + }; + } + } + steps.push({ id: step.id, name: step.name as string | undefined, @@ -579,6 +623,7 @@ export function yamlToFormState( duration: step.duration as string | undefined, text: step.text as string | undefined, channel: step.channel as string | undefined, + replyInThread: step.reply_in_thread === true, to: step.to as string | undefined, url: step.url as string | undefined, method: step.method as string | undefined, From de8a2741c7598c27e0d27cd4562d76b947934e2f Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 20 Aug 2026 19:01:18 -0700 Subject: [PATCH 06/38] feat(desktop-messages): show compact Buzz link metadata (#6252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** improvement **User Impact:** Buzz links in messages now resolve into compact, descriptive chips with predictable navigation, while tooltips use the neutral secondary surface consistently across light and dark themes. **Problem:** Raw Buzz-native links and opaque identifiers make message context difficult to scan, while oversized previews disrupt the conversation. Missing, delayed, or deleted metadata also needs a stable presentation that does not mislead users or break navigation. **Solution:** Keep message and issue chips metadata-independent: message chips render only the channel name, while issue chips render only the repository name—never fetched content/title or an event hash. Fetched context remains available in each hover tooltip. Other native links continue to resolve into concise inline labels, with muted secondary fallbacks, distinct deleted states, and existing navigation paths preserved. This also makes two explicit shared-tooltip behavior changes: 1. **Color:** shared and rich tooltips move from the primary color pair to the neutral secondary semantic pair, including dedicated huddle-token handling and matching rich-descendant foregrounds. 2. **Hover behavior:** tooltip roots set Radix `disableHoverableContent`, and tooltip content is pointer-transparent, so moving off the trigger dismisses the tooltip instead of letting the pointer enter and keep the popover open. Together these preserve readable contrast, authoritative project context, and predictable hover dismissal.
File changes **desktop/src/features/communities/useCommunityInit.ts** Primes channel metadata needed to resolve native links promptly. **desktop/src/features/messages/lib/messageLinkMetadata.test.mjs** Covers message preview extraction, fallbacks, and deleted-message semantics. **desktop/src/features/messages/lib/messageLinkMetadata.ts** Normalizes fetched message metadata into compact labels and status-aware presentation. **desktop/src/features/messages/ui/SentFromThreadLine.tsx** Provides thread context to linked-message metadata resolution. **desktop/src/shared/lib/useResolvedLinkPreviews.ts** Exposes resolved preview state for native entity chips. **desktop/src/shared/styles/globals/markdown.css** Adds compact chip layout and semantic disabled-state styling. **desktop/src/shared/styles/globals/theme.css** Defines the semantic colors used by unavailable and deleted chips. **desktop/src/shared/ui/markdown.test.mjs** Exercises native-link rendering and provider-backed entity metadata behavior. **desktop/src/shared/ui/markdown.tsx** Routes native markdown links through metadata-aware chip components without changing navigation contracts. **desktop/src/shared/ui/markdown/ChannelDeepLink.test.mjs** Covers resolved, unresolved, private, archived, and forum channel presentations. **desktop/src/shared/ui/markdown/ChannelDeepLink.tsx** Renders channel names and concise channel metadata inline with stable fallbacks. **desktop/src/shared/ui/markdown/MessageLinkPill.tsx** Renders loading, ready, unavailable, DM, forum, and deleted message states compactly. **desktop/src/shared/ui/markdown/entityLinks.tsx** Enriches repository, issue, and pull-request chips and uses containing-project context in tooltips. **desktop/src/shared/ui/markdown/types.ts** Extends markdown runtime metadata required by native chip rendering. **desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts** Fetches and classifies linked-message metadata while preserving navigable fallbacks. **desktop/src/testing/e2eBridge.ts** Adds deterministic metadata controls for runtime visual and navigation tests. **desktop/tailwind.config.js** Makes semantic disabled colors available to chip styles. **desktop/tests/e2e/entity-link-recipient-cards.spec.ts** Verifies entity labels, project-aware tooltips, delayed metadata, and navigation behavior. **desktop/tests/e2e/navigation.spec.ts** Verifies channel and message chip states, metadata resolution, deletion handling, and navigation. **desktop/src/shared/ui/tooltip.tsx** Migrates the shared tooltip surface from the primary color pair to the semantic secondary pair. It also sets Radix `disableHoverableContent` and makes tooltip content pointer-transparent so leaving the trigger dismisses the tooltip predictably. **desktop/src/features/agents/ui/RestartDiffBadge.tsx** Aligns restart-diff tooltip values and supporting copy with the secondary tooltip foreground. **desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx** Aligns rich team tooltip descriptions, persona chips, avatars, and names with secondary tooltip semantics. **desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx** Uses the matching secondary tooltip foreground for project author metadata. **desktop/src/features/projects/ui/ProjectCards.tsx** Aligns repository-unavailable tooltip descriptions with the secondary tooltip foreground. **desktop/src/shared/styles/globals/utilities.css** Makes huddle tooltips consume their dedicated palette directly without misleading primary or secondary aliases. **desktop/src/shared/styles/globals/tooltipSemantics.test.mjs** Pins direct huddle-token consumption and rejects semantic alias drift. **desktop/tests/e2e/tooltip-semantics.spec.ts** Verifies simple and rich tooltip surfaces and descendant tokens in Buzz light and Catppuccin Mocha dark themes. **desktop/playwright.config.ts** Registers tooltip semantic coverage in the desktop smoke suite.
## Reproduction steps 1. Run the desktop app and open a message containing Buzz links for a channel, message, repository, issue, and pull request. 2. Confirm message links show only the channel name and issue links show only the repository name inline, with no fetched content/title or event hash; confirm other native links remain compact rather than rendering raw identifiers or expanded previews. 3. Hover the chips and confirm the neutral secondary tooltip adds concise context without duplicating the visible label. 4. Open each chip and confirm it follows the same channel, message, repository, issue, or pull-request navigation as before. 5. Inspect links while metadata is loading, unavailable, or definitively deleted and confirm the fallback remains compact, legible, and navigable where appropriate. 6. In Buzz light and Catppuccin Mocha dark themes, compare a simple channel-members tooltip and the rich Add agents team tooltip; both should use secondary semantics with readable nested content. 7. Move the pointer from a trigger toward its tooltip; confirm the tooltip dismisses rather than remaining open as hoverable content. ## Screenshots The matrix below covers both the default light theme and Catppuccin Mocha with an orange accent. Message/issue-chip rows affected by the stable-label pivot are temporarily omitted until they can be recaptured from the real built UI; the prior images showed the obsolete expanding inline label. | State | Default light | Dark · orange accent | |---|---|---| | Public stream channel tooltip | ![Public stream channel tooltip — default light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/01-public-stream-channel-tooltip.png) | ![Public stream channel tooltip — dark orange accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/01-public-stream-channel-tooltip.png) | | Private forum channel tooltip | ![Private forum channel tooltip — default light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/02-private-forum-channel-tooltip.png) | ![Private forum channel tooltip — dark orange accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/02-private-forum-channel-tooltip.png) | | Public forum channel tooltip | ![Public forum channel tooltip — default light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/03-forum-channel-tooltip.png) | ![Public forum channel tooltip — dark orange accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/03-forum-channel-tooltip.png) | | Channel references, including unresolved | ![Channel references, including unresolved — default light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/04-unresolved-channel-reference.png) | ![Channel references, including unresolved — dark orange accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/04-unresolved-channel-reference.png) | | Unknown channel permalink | ![Unknown channel permalink — default light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/05-unknown-channel-permalink.png) | ![Unknown channel permalink — dark orange accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/05-unknown-channel-permalink.png) | | Archived channel tooltip | ![Archived channel tooltip — default light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/06-archived-channel-tooltip.png) | ![Archived channel tooltip — dark orange accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/06-archived-channel-tooltip.png) | | Repository entity | ![Repository entity — default light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/13-repository-entity.png) | ![Repository entity — dark orange accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/13-repository-entity.png) | | Pull request entity | ![Pull request entity — default light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/14-pull-request-entity.png) | ![Pull request entity — dark orange accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/14-pull-request-entity.png) | | Entity metadata fallback | ![Corrected entity metadata fallback tooltip — default light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/17-entity-metadata-fallback-corrected-light.png) | ![Corrected entity metadata fallback tooltip — dark orange accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/17-entity-metadata-fallback-corrected-dark-orange.png) | ### Tooltip semantic surface spot check | Buzz light | Catppuccin Mocha dark | |---|---| | ![Rich team tooltip on the secondary surface in Buzz light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6290/buzz-team-tooltip.png) | ![Rich team tooltip on the secondary surface in Catppuccin Mocha dark](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6290/catppuccin-mocha-team-tooltip.png) | --------- Signed-off-by: Taylor Ho Co-authored-by: Carl --- desktop/playwright.config.ts | 1 + .../features/agents/ui/RestartDiffBadge.tsx | 10 +- .../channels/ui/AddChannelBotTeamsSection.tsx | 10 +- .../features/communities/useCommunityInit.ts | 2 + .../lib/composerMessageLinkNode.test.mjs | 15 +- .../messages/lib/composerMessageLinkNode.ts | 12 +- .../messages/lib/messageLinkMetadata.test.mjs | 24 ++ .../messages/lib/messageLinkMetadata.ts | 29 ++ .../messages/lib/remarkEntityLinks.test.mjs | 1 + .../messages/lib/remarkEntityLinks.ts | 4 +- .../messages/lib/useDraftRootStatus.ts | 13 +- .../messages/ui/SentFromThreadLine.tsx | 3 + .../onboarding/ui/RuntimeErrorTooltip.tsx | 4 +- .../projects/ui/ProjectAuthorIdentity.tsx | 2 +- .../src/features/projects/ui/ProjectCards.tsx | 2 +- desktop/src/shared/lib/eventLookupError.ts | 11 + .../lib/useResolvedLinkPreviews.test.mjs | 2 +- .../src/shared/lib/useResolvedLinkPreviews.ts | 55 +-- .../src/shared/styles/globals/markdown.css | 50 +++ .../styles/globals/tooltipSemantics.test.mjs | 27 ++ .../src/shared/styles/globals/utilities.css | 18 +- desktop/src/shared/ui/markdown.test.mjs | 170 ++++++++- desktop/src/shared/ui/markdown.tsx | 41 +- .../src/shared/ui/markdown/BuzzLinkChip.tsx | 77 +++- .../ui/markdown/ChannelDeepLink.test.mjs | 34 ++ .../shared/ui/markdown/ChannelDeepLink.tsx | 144 +++++-- .../shared/ui/markdown/MessageLinkPill.tsx | 165 +++++++- .../src/shared/ui/markdown/entityLinks.tsx | 201 +++++++++- desktop/src/shared/ui/markdown/types.ts | 1 + .../ui/markdown/useInlineTooltipPosition.ts | 101 +++++ .../ui/markdown/useMessageLinkMetadata.ts | 125 ++++++ desktop/src/shared/ui/mentionChip.ts | 14 + desktop/src/shared/ui/tooltip.tsx | 12 +- desktop/src/testing/e2eBridge.ts | 8 +- .../e2e/entity-link-recipient-cards.spec.ts | 361 +++++++++++++++++- desktop/tests/e2e/navigation.spec.ts | 246 +++++++++++- .../e2e/onboarding-agent-defaults.spec.ts | 8 + .../tests/e2e/project-issue-comments.spec.ts | 14 +- desktop/tests/e2e/tooltip-semantics.spec.ts | 160 ++++++++ 39 files changed, 1967 insertions(+), 210 deletions(-) create mode 100644 desktop/src/features/messages/lib/messageLinkMetadata.test.mjs create mode 100644 desktop/src/features/messages/lib/messageLinkMetadata.ts create mode 100644 desktop/src/shared/lib/eventLookupError.ts create mode 100644 desktop/src/shared/styles/globals/tooltipSemantics.test.mjs create mode 100644 desktop/src/shared/ui/markdown/ChannelDeepLink.test.mjs create mode 100644 desktop/src/shared/ui/markdown/useInlineTooltipPosition.ts create mode 100644 desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts create mode 100644 desktop/tests/e2e/tooltip-semantics.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index b1a2d5623b..ff8a0e7703 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ testMatch: [ "**/smoke.spec.ts", "**/sidebar-offcanvas-rail.spec.ts", + "**/tooltip-semantics.spec.ts", "**/search-scope-screenshots.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", "**/identity-key-help.spec.ts", diff --git a/desktop/src/features/agents/ui/RestartDiffBadge.tsx b/desktop/src/features/agents/ui/RestartDiffBadge.tsx index 1bdb781226..e15a57fde4 100644 --- a/desktop/src/features/agents/ui/RestartDiffBadge.tsx +++ b/desktop/src/features/agents/ui/RestartDiffBadge.tsx @@ -88,8 +88,8 @@ function ChangeDescription({ change }: { change: RestartChange }) { const TOOLTIP_CAP = 6; /** - * `tooltip` — renders inside the dark `bg-primary` tooltip; uses - * `text-primary-foreground` variants for contrast there. + * `tooltip` — renders inside the semantic secondary tooltip surface; uses + * `text-secondary-foreground` variants for contrast there. * `inline` — renders inside the amber Runtime banner or other light * surfaces; inherits foreground from the container instead. */ @@ -107,10 +107,10 @@ function DiffList({ cap !== undefined && entries.length > cap ? entries.length - cap : 0; const valueClass = - variant === "tooltip" ? "text-primary-foreground/80" : "text-foreground"; + variant === "tooltip" ? "text-secondary-foreground/80" : "text-foreground"; const overflowClass = variant === "tooltip" - ? "text-primary-foreground/60" + ? "text-secondary-foreground/60" : "text-muted-foreground"; return ( @@ -180,7 +180,7 @@ export function RestartDiffBadge({

Config changed since last start:

-

+

{autoRestartEnabled ? AUTO_RESTART_ON_BLURB : AUTO_RESTART_OFF_BLURB}

diff --git a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx index 512ee6899f..326866cf63 100644 --- a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx @@ -143,7 +143,7 @@ export function AddChannelBotTeamsSection({

{team.name}

{team.description ? ( -

+

{team.description}

) : null} @@ -153,15 +153,17 @@ export function AddChannelBotTeamsSection({ inChannelPersonaIds?.has(persona.id) ?? false; return (
- + {persona.displayName} {personaInChannel ? ( diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index e1cdee41a7..e792358713 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -34,6 +34,7 @@ import { resetAvatarPresentations } from "@/features/profile/avatarPresentationS import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; import { clearMarkdownNodeCache } from "@/shared/ui/markdown/nodeCache"; +import { resetMessageLinkMetadataCache } from "@/shared/ui/markdown/useMessageLinkMetadata"; import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState"; import { @@ -77,6 +78,7 @@ async function resetCommunityState({ resetLinkPreviewPreparations(); clearSearchHitEventCache(); clearMarkdownNodeCache(); + resetMessageLinkMetadataCache(); } type CommunityInitResult = diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs index 5aee675592..3695275762 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs @@ -21,6 +21,8 @@ const OWNER = "a".repeat(64); const REPO_HREF = `buzz://repo?owner=${OWNER}&d=buzz-world`; const ISSUE_ID = "b".repeat(64); const ISSUE_HREF = `buzz://issue?id=${ISSUE_ID}&owner=${OWNER}&d=buzz-world`; +const PR_ID = "c".repeat(64); +const PR_HREF = `buzz://pr?id=${PR_ID}&owner=${OWNER}&d=buzz-world`; test("resolves a composer preview and canonicalizes the underlying href", () => { assert.deepEqual( @@ -205,7 +207,9 @@ test("composer node uses the sent-message chip presentation", () => { assert.match(rendered[1].class, /inline-chip-with-icon/); assert.match(rendered[1].class, /inline-chip-icon-message/); assert.equal(rendered[1]["data-buzz-link"], ""); - assert.equal(rendered[2], "general · root-eve"); + // Channel label only — no event hash, so the chip does not change width when + // the draft is sent and the rendered chip resolves its metadata. + assert.equal(rendered[2], "general"); }); test("composer node renders channel and entity chip presentations", () => { @@ -233,7 +237,14 @@ test("composer node renders channel and entity chip presentations", () => { const issue = render(ISSUE_HREF); assert.equal(issue[1]["data-buzz-link-kind"], "issue"); assert.match(issue[1].class, /inline-chip-icon-issue/); - assert.equal(issue[2], "buzz-world · bbbbbbbb"); + // Repository name only — the rendered chip never widens into the issue + // title, so the composer must not widen into the event hash either. + assert.equal(issue[2], "buzz-world"); + + const pullRequest = render(PR_HREF); + assert.equal(pullRequest[1]["data-buzz-link-kind"], "pr"); + assert.match(pullRequest[1].class, /inline-chip-icon-pr/); + assert.equal(pullRequest[2], "buzz-world"); }); test("markdown rendering stores identity in attributes, not visible id text", () => { diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.ts b/desktop/src/features/messages/lib/composerMessageLinkNode.ts index a01109e010..9587c312ca 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.ts +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.ts @@ -232,7 +232,9 @@ function composerLinkPresentation( "data-message-link": "", }, icon: "message", - label: `${resolvedChannelName} · ${message.value.messageId.slice(0, 8)}`, + // Matches the rendered inline message chip, which never shows the event + // hash — the label must not change when the draft is sent. + label: resolvedChannelName, }; } @@ -276,10 +278,10 @@ function composerLinkPresentation( channelName: "", dataAttributes: { "data-buzz-link-kind": entity.value.type }, icon: entity.value.type, - label: - entity.value.type === "repo" || entity.value.type === "project" - ? entity.value.dtag - : `${entity.value.dtag} · ${shortId}`, + // Entity chips use only stable link-derived identity. Fetched metadata is + // reserved for sent-message tooltips/cards, so every composer chip keeps the + // same label after send and throughout metadata resolution. + label: entity.value.dtag, }; } diff --git a/desktop/src/features/messages/lib/messageLinkMetadata.test.mjs b/desktop/src/features/messages/lib/messageLinkMetadata.test.mjs new file mode 100644 index 0000000000..5b44c799c8 --- /dev/null +++ b/desktop/src/features/messages/lib/messageLinkMetadata.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { summarizeMessageLinkContent } from "./messageLinkMetadata.ts"; + +test("summarizeMessageLinkContent projects markdown to bounded plain text", () => { + assert.equal( + summarizeMessageLinkContent( + "**Hello** [team](https://example.com)\n\n![secret](https://example.com/a.png) ||hidden||", + ), + "Hello team", + ); + assert.equal( + summarizeMessageLinkContent("https://example.com"), + "No message text", + ); +}); + +test("summarizeMessageLinkContent truncates on grapheme-safe character boundaries", () => { + const result = summarizeMessageLinkContent(`Lead ${"🦄".repeat(200)}`); + assert.ok(Array.from(result).length <= 160); + assert.ok(result.endsWith("…")); + assert.ok(!result.includes("\ud83e") || result.includes("🦄")); +}); diff --git a/desktop/src/features/messages/lib/messageLinkMetadata.ts b/desktop/src/features/messages/lib/messageLinkMetadata.ts new file mode 100644 index 0000000000..848b207d69 --- /dev/null +++ b/desktop/src/features/messages/lib/messageLinkMetadata.ts @@ -0,0 +1,29 @@ +const MESSAGE_LINK_SNIPPET_MAX_LENGTH = 160; + +/** Build a compact, non-recursive plain-text preview for a linked message. */ +export function summarizeMessageLinkContent(content: string): string { + const normalized = Array.from(content, (character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f) + ? " " + : character; + }) + .join("") + .replace(/\|\|[^|]*(?:\|(?!\|)[^|]*)*\|\|/g, " ") + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/?/g, " ") + .replace(/[`*_~>#|]/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!normalized) return "No message text"; + + const characters = Array.from(normalized); + if (characters.length <= MESSAGE_LINK_SNIPPET_MAX_LENGTH) return normalized; + const clipped = characters + .slice(0, MESSAGE_LINK_SNIPPET_MAX_LENGTH - 1) + .join(""); + const lastSpace = clipped.lastIndexOf(" "); + const snippet = lastSpace > 96 ? clipped.slice(0, lastSpace) : clipped; + return `${snippet.trimEnd()}…`; +} diff --git a/desktop/src/features/messages/lib/remarkEntityLinks.test.mjs b/desktop/src/features/messages/lib/remarkEntityLinks.test.mjs index 1d7fdeacb3..99fa0ed510 100644 --- a/desktop/src/features/messages/lib/remarkEntityLinks.test.mjs +++ b/desktop/src/features/messages/lib/remarkEntityLinks.test.mjs @@ -17,6 +17,7 @@ test("turns every bare Buzz entity permalink family into a chip node", () => { const id = "cd".repeat(32); const links = [ `buzz://repo?owner=${owner}&d=buzz`, + `buzz://project?owner=${owner}&d=onboarding`, `buzz://pr?id=${id}&owner=${owner}&d=buzz`, `buzz://issue?id=${id}&owner=${owner}&d=buzz`, ]; diff --git a/desktop/src/features/messages/lib/remarkEntityLinks.ts b/desktop/src/features/messages/lib/remarkEntityLinks.ts index 41cf4af20b..85ba43f774 100644 --- a/desktop/src/features/messages/lib/remarkEntityLinks.ts +++ b/desktop/src/features/messages/lib/remarkEntityLinks.ts @@ -1,7 +1,7 @@ -/** Detect bare `buzz://pr|issue|repo?…` URLs in markdown text nodes. */ +/** Detect bare `buzz://pr|issue|repo|project?…` URLs in markdown text nodes. */ import { createRemarkPrefixPlugin } from "../../../shared/lib/createRemarkPrefixPlugin.ts"; -const ENTITY_URL_PATTERN = /buzz:\/\/(?:pr|issue|repo)\?[^\s<>"')\]]+/g; +const ENTITY_URL_PATTERN = /buzz:\/\/(?:pr|issue|repo|project)\?[^\s<>"')\]]+/g; const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?]+$/; export default function remarkEntityLinks() { diff --git a/desktop/src/features/messages/lib/useDraftRootStatus.ts b/desktop/src/features/messages/lib/useDraftRootStatus.ts index baa1f0926d..4ea219c251 100644 --- a/desktop/src/features/messages/lib/useDraftRootStatus.ts +++ b/desktop/src/features/messages/lib/useDraftRootStatus.ts @@ -1,6 +1,7 @@ import { useQueries } from "@tanstack/react-query"; import { getEventById } from "@/shared/api/tauri"; +import { isDefinitiveEventNotFound } from "@/shared/lib/eventLookupError"; /** * Root-existence status for a thread-draft's parent event. @@ -18,18 +19,8 @@ import { getEventById } from "@/shared/api/tauri"; */ export type RootStatus = "checking" | "available" | "deleted" | "error"; -const EVENT_NOT_FOUND_MESSAGE = "event not found"; - export function classifyError(err: unknown): RootStatus { - // Only the definitive relay-returned string maps to `deleted`. - // Every other failure (transport, auth, serialization) is `error`. - if (typeof err === "string" && err.includes(EVENT_NOT_FOUND_MESSAGE)) { - return "deleted"; - } - if (err instanceof Error && err.message.includes(EVENT_NOT_FOUND_MESSAGE)) { - return "deleted"; - } - return "error"; + return isDefinitiveEventNotFound(err) ? "deleted" : "error"; } /** diff --git a/desktop/src/features/messages/ui/SentFromThreadLine.tsx b/desktop/src/features/messages/ui/SentFromThreadLine.tsx index 9740fe31e4..84e3ef5d68 100644 --- a/desktop/src/features/messages/ui/SentFromThreadLine.tsx +++ b/desktop/src/features/messages/ui/SentFromThreadLine.tsx @@ -44,6 +44,9 @@ export function SentFromThreadLine({ channels={channels} interactive link={link} + onOpenChannel={(targetChannelId) => { + void goChannel(targetChannelId); + }} onOpenMessageLink={onOpenMessageLink} resolveChannelReference threadExcerpt={reference.rootExcerpt} diff --git a/desktop/src/features/onboarding/ui/RuntimeErrorTooltip.tsx b/desktop/src/features/onboarding/ui/RuntimeErrorTooltip.tsx index 6a3d8ef319..26b778ff6d 100644 --- a/desktop/src/features/onboarding/ui/RuntimeErrorTooltip.tsx +++ b/desktop/src/features/onboarding/ui/RuntimeErrorTooltip.tsx @@ -18,7 +18,7 @@ export function RuntimeErrorTooltip({ testId, }: RuntimeErrorTooltipProps) { return ( - + diff --git a/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx b/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx index be7ac53d2e..c145f5f9d7 100644 --- a/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx +++ b/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx @@ -66,7 +66,7 @@ export function ProjectAuthorIdentity({ /> {label} - + {roleLabel} diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index 45168744c9..b8c3ffd273 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -333,7 +333,7 @@ function RepositoryUnavailableIndicator({

{label}

-

{description}

+

{description}

); diff --git a/desktop/src/shared/lib/eventLookupError.ts b/desktop/src/shared/lib/eventLookupError.ts new file mode 100644 index 0000000000..ad401868fb --- /dev/null +++ b/desktop/src/shared/lib/eventLookupError.ts @@ -0,0 +1,11 @@ +const EVENT_NOT_FOUND_MESSAGE = "event not found"; + +/** Only the relay's definitive zero-row signal proves that an event was deleted. */ +export function isDefinitiveEventNotFound(error: unknown): boolean { + if (typeof error === "string") { + return error.includes(EVENT_NOT_FOUND_MESSAGE); + } + return ( + error instanceof Error && error.message.includes(EVENT_NOT_FOUND_MESSAGE) + ); +} diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs b/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs index 6080e32c8f..e413f9c7c2 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs @@ -359,7 +359,7 @@ test("Buzz PR metadata includes repository identity and trusted root context", a ); assert.equal(result?.siteName, "Buzz Desktop"); assert.equal(result?.title, "Restore entity cards"); - assert.equal(result?.description, "Open · fix/cards → release · 1234567"); + assert.equal(result?.description, null); assert.equal(result?.faviconDataUrl, null); assert.equal(result?.imageDataUrl, null); }); diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index 8b210f6ab3..4591d54e7d 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -12,10 +12,6 @@ import { eventToProjectPullRequest } from "@/features/projects/projectPullReques import { KIND_GIT_ISSUE, KIND_GIT_PULL_REQUEST, - KIND_GIT_STATUS_CLOSED, - KIND_GIT_STATUS_DRAFT, - KIND_GIT_STATUS_MERGED, - KIND_GIT_STATUS_OPEN, KIND_PROJECT_ANNOUNCEMENT, KIND_REPO_ANNOUNCEMENT, } from "@/shared/constants/kinds"; @@ -244,13 +240,6 @@ export async function loadLinkPreviewMetadata( ): Promise { return (await metadataLoader.load(href)).metadata; } -const ENTITY_STATUS_KINDS = [ - KIND_GIT_STATUS_OPEN, - KIND_GIT_STATUS_MERGED, - KIND_GIT_STATUS_CLOSED, - KIND_GIT_STATUS_DRAFT, -]; - type EntityEventFetcher = ( filter: Parameters[0], ) => Promise; @@ -279,9 +268,6 @@ export async function fetchBuzzEntityMetadata( "#d": [dtag], limit: 1, }); - // Repository maps are only needed to resolve a project's repository read - // models, which the card does not show — empty maps still validate the - // announcement envelope and resolve name/description. const project = projectEvents .map((event) => eventToExplicitProject(event, new Map(), new Map())) .find((candidate) => candidate?.projectAddress === projectAddress); @@ -347,33 +333,17 @@ export async function fetchBuzzEntityMetadata( }); if (!root) return null; - const trustedAuthors = [...new Set([root.pubkey.toLowerCase(), owner])]; - const statusEvents = await fetchEvents({ - kinds: ENTITY_STATUS_KINDS, - authors: trustedAuthors, - "#e": [id], - limit: 20, - }); - if (type === "issue") { - const issue = eventToProjectIssue(root, statusEvents); - return { - ...base, - title: issue.title, - description: compactMetadata([issue.status, ...issue.labels.slice(0, 2)]), - }; - } - - const pullRequest = eventToProjectPullRequest(root, [], [], statusEvents); - const source = pullRequest.branchName; - const target = pullRequest.targetBranch ?? repository.defaultBranch; + const title = + type === "issue" + ? eventToProjectIssue(root).title + : eventToProjectPullRequest(root).title; return { ...base, - title: pullRequest.title, - description: compactMetadata([ - pullRequest.status, - source ? `${source} → ${target}` : null, - pullRequest.commit?.slice(0, 7), - ]), + title, + // Issue and PR bodies are markdown and often begin with headings. Keep + // their fetched subject as tooltip/card context without fetching or + // flattening body/status metadata into an unrendered description string. + description: null, }; } @@ -381,6 +351,13 @@ const entityMetadataLoader = createMetadataLoader({ fetcher: fetchBuzzEntityMetadata, }); +/** Share deduplicated relay-native entity metadata across cards and inline tooltips. */ +export async function loadBuzzEntityMetadata( + href: string, +): Promise { + return (await entityMetadataLoader.load(href)).metadata; +} + /** Clear ephemeral metadata when the active relay/community changes. */ export function resetLinkPreviewMetadataCache(): void { metadataLoader.reset(); diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index 7d0a238c17..9be4548249 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -87,6 +87,31 @@ color: hsl(var(--primary)); } +.message-markdown .mention-chip.wrapping-inline-chip { + display: inline; + line-height: calc( + 1em + + var(--inline-chip-padding-block-start) + + var(--inline-chip-padding-block-end) + + 0.25rem + ); + overflow-wrap: anywhere; + text-align: left; + white-space: normal; +} + +.message-markdown .mention-chip.buzz-link-unavailable, +.message-markdown .mention-chip.buzz-link-unavailable:hover { + background: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground) / 0.7); +} + +.message-markdown .mention-chip.buzz-link-deleted, +.message-markdown .mention-chip.buzz-link-deleted:hover { + background: hsl(var(--destructive) / 0.12); + color: hsl(var(--destructive)); +} + .message-markdown .mention-chip.inbox-channel-chip { background: hsl(var(--muted)); color: hsl(var(--muted-foreground) / 0.82); @@ -137,6 +162,7 @@ .message-markdown.inbox-preview-markdown .inline-code-chip, .message-markdown.inbox-preview-markdown :not(pre) > code { display: inline; + line-height: 1; overflow: visible; overflow-wrap: normal; text-overflow: clip; @@ -166,6 +192,30 @@ transform: translateY(-50%); } +.message-markdown .wrapping-inline-chip.inline-chip-with-icon { + padding-left: var(--inline-chip-padding-inline); +} + +.message-markdown .wrapping-inline-chip.inline-chip-with-icon::before { + display: none; +} + +.message-markdown .inline-chip-leading-fragment.inline-chip-with-icon { + position: relative; + display: inline-flex; + align-items: center; + padding-left: calc( + var(--inline-chip-icon-size) + + var(--inline-chip-icon-gap) + ); + white-space: nowrap; +} + +.message-markdown .inline-chip-leading-fragment.inline-chip-with-icon::before { + display: block; + left: 0; +} + .message-markdown .inline-chip-icon-message::before { mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z' fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E") diff --git a/desktop/src/shared/styles/globals/tooltipSemantics.test.mjs b/desktop/src/shared/styles/globals/tooltipSemantics.test.mjs new file mode 100644 index 0000000000..7fdc1d6097 --- /dev/null +++ b/desktop/src/shared/styles/globals/tooltipSemantics.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const utilitiesCss = readFileSync( + new URL("./utilities.css", import.meta.url), + "utf8", +); +const huddleTooltipRule = utilitiesCss.match( + /\.buzz-huddle-tooltip\s*\{([\s\S]*?)\n\s*\}/, +)?.[1]; + +test("huddle tooltips consume their dedicated semantic tokens", () => { + assert.ok(huddleTooltipRule, "missing .buzz-huddle-tooltip rule"); + assert.match( + huddleTooltipRule, + /background:\s*hsl\(\s*var\(--huddle-tooltip-surface,/, + ); + assert.match( + huddleTooltipRule, + /color:\s*hsl\(\s*var\(\s*--huddle-tooltip-foreground,/, + ); + assert.doesNotMatch( + huddleTooltipRule, + /--(?:primary|secondary)(?:-foreground)?/, + ); +}); diff --git a/desktop/src/shared/styles/globals/utilities.css b/desktop/src/shared/styles/globals/utilities.css index a9da737bce..0d5e0f453f 100644 --- a/desktop/src/shared/styles/globals/utilities.css +++ b/desktop/src/shared/styles/globals/utilities.css @@ -38,16 +38,9 @@ } .buzz-huddle-tooltip { - --primary: var( - --huddle-tooltip-surface, - var(--huddle-control-surface, 0 0% 20%) + background: hsl( + var(--huddle-tooltip-surface, var(--huddle-control-surface, 0 0% 20%)) ); - --primary-foreground: var( - --huddle-tooltip-foreground, - var(--huddle-control-foreground, 0 0% 98%) - ); - - background: hsl(var(--primary)); border: 1px solid hsl( var( @@ -57,7 +50,12 @@ 0.72 ); box-shadow: 0 10px 22px rgb(0 0 0 / 35%); - color: hsl(var(--primary-foreground)); + color: hsl( + var( + --huddle-tooltip-foreground, + var(--huddle-control-foreground, 0 0% 98%) + ) + ); } .buzz-huddle-drawer .buzz-huddle-control-button, diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 5e829f6243..f84babddd2 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; // These are copied here to avoid importing from .ts files that depend on // React (which isn't resolvable outside the bundler). Same pattern as @@ -656,6 +657,13 @@ test("buzzDeepLinkUrlTransform: preserves buzz://repo entity link href", () => { assert.doesNotMatch(html, /href=""/); }); +test("buzzDeepLinkUrlTransform: preserves buzz://project autolink href", () => { + const projectLink = `buzz://project?owner=${OWNER_HEX}&d=onboarding`; + const html = renderMarkdown(`<${projectLink}>`); + assert.match(html, /href="buzz:\/\/project\?/); + assert.doesNotMatch(html, /href=""/); +}); + test("buzzDeepLinkUrlTransform: strips malformed buzz://pr (unknown param)", () => { // Strict parser rejects unknown params — transform falls back to default sanitizer. const html = renderMarkdown( @@ -1080,32 +1088,144 @@ test("bare Buzz permalinks render cohesive icon-prefixed chips", () => { }); const html = renderToStaticMarkup( React.createElement( - MarkdownRuntimeContext.Provider, - { - value: { - channels: [{ id: channelId, name: "engineering" }], - onOpenChannel: () => {}, - onOpenEntityLink: () => {}, - onOpenMessageLink: () => {}, - relayOrigin: null, + QueryClientProvider, + { client: new QueryClient() }, + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + channels: [{ id: channelId, name: "engineering" }], + onOpenChannel: () => {}, + onOpenEntityLink: () => {}, + onOpenMessageLink: () => {}, + relayOrigin: null, + }, }, - }, - markdown, + markdown, + ), ), ); + const visibleText = html.replace(/<[^>]+>/g, ""); assert.equal((html.match(/data-buzz-link=""/g) ?? []).length, 6); - assert.equal((html.match(/inline-chip-icon-message/g) ?? []).length, 2); - assert.equal((html.match(/>engineering · c3b589faengineering]*>ebuzz-world · c3b589fabuzz-world { + const renderEntityChip = (href) => + renderToStaticMarkup( + renderEntityLinkAnchor({ + children: null, + href, + onOpenEntityLink: () => {}, + relayOrigin: null, + }), + ); + + const issueHtml = renderEntityChip( + `buzz://issue?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`, + ); + const issueText = issueHtml.replace(/<[^>]+>/g, ""); + assert.equal(issueText, "buzz-world"); + assert.doesNotMatch(issueText, /c3b589fa/); + assert.doesNotMatch(issueText, /·/); + // Identity, icon, and navigation affordances survive the shorter label. + assert.match(issueHtml, /data-buzz-link-kind="issue"/); + assert.match(issueHtml, /inline-chip-icon-issue/); + assert.match( + issueHtml, + /aria-label="Open issue c3b589fa in repository buzz-world"/, + ); + + // Pull-request chips follow the same stable inline identity policy. + const pullRequestText = renderEntityChip( + `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`, + ).replace(/<[^>]+>/g, ""); + assert.equal(pullRequestText, "buzz-world"); + assert.doesNotMatch(pullRequestText, /c3b589fa/); + assert.doesNotMatch(pullRequestText, /·/); +}); + +test("inline entity chip labels are bounded without truncating their accessible names", () => { + const longRepository = `repo-${"x".repeat(50)}`; + const html = renderToStaticMarkup( + renderEntityLinkAnchor({ + children: null, + href: `buzz://repo?owner=${OWNER_HEX}&d=${longRepository}`, + onOpenEntityLink: () => {}, + relayOrigin: null, + }), + ); + const visibleText = html.replace(/<[^>]+>/g, ""); + assert.equal(Array.from(visibleText).length, 48); + assert.match(visibleText, /…$/); + assert.match( + html, + new RegExp(`aria-label="Open repository ${longRepository}"`), + ); +}); + +test("inline message chips omit fetched metadata and the event hash", () => { + const channelId = "580ca78b-9dae-46f3-8854-bd671853ba32"; + const markdown = renderCachedMarkdown({ + components: createMarkdownComponents(true, false), + content: `buzz://message?channel=${channelId}&id=${EVENT_HEX}`, + variant: "inline-message-chip-metadata-test", + }); + // A readable channel is the case that used to swap the chip label from the + // truncated event hash to the fetched snippet once metadata resolved. + const html = renderToStaticMarkup( + React.createElement( + QueryClientProvider, + { client: new QueryClient() }, + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + channels: [ + { + id: channelId, + isMember: true, + name: "engineering", + visibility: "open", + }, + ], + onOpenChannel: () => {}, + onOpenEntityLink: () => {}, + onOpenMessageLink: () => {}, + relayOrigin: null, + }, + }, + markdown, + ), + ), + ); + + const visibleText = html.replace(/<[^>]+>/g, ""); + assert.equal((html.match(/data-message-link=""/g) ?? []).length, 1); + assert.equal(visibleText.trim(), "engineering"); + assert.doesNotMatch(visibleText, /c3b589fa/); + assert.doesNotMatch(visibleText, /·/); }); test("authored Buzz permalink labels remain ordinary links", () => { @@ -1174,8 +1294,11 @@ test("bare Buzz permalinks shorten unavailable channel identifiers", () => { ), ); - assert.match(html, />580ca78b · c3b589fa580ca78b]*>5<\/span>80ca78b/g) ?? []) + .length, + 2, + ); assert.doesNotMatch(html, /#channel/); }); @@ -1204,7 +1327,9 @@ test("channel references replace the authored hash with the channel icon", () => ); assert.match(html, /inline-chip-icon-channel/); - assert.match(html, />engineering]*>e]+>/g, ""), /engineering/); assert.doesNotMatch(html, />#engineering { }); const html = renderToStaticMarkup(el); assert.match(html, /data-buzz-link=""/); - assert.match(html, /
); diff --git a/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs b/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs index 7febdf8b67..36066132c4 100644 --- a/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs +++ b/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs @@ -6,6 +6,7 @@ import { buildProjectSelectionAgentContext, buildProjectsOverviewAgentContext, projectDetailAgentContextBlock, + splitProjectDetailAgentContext, stripProjectDetailAgentContext, untrustedPromptValue, withProjectSelectionAgentContext, @@ -215,8 +216,44 @@ test("selected project context enforces a final serialization budget", () => { }); test("strips hidden page context from the displayed user message", () => { - const content = `Explain this file${projectDetailAgentContextBlock( + const payload = projectDetailAgentContextBlock( buildProjectDetailAgentContext(base), - )}`; + ); + const content = `Explain this file${payload}`; assert.equal(stripProjectDetailAgentContext(content), "Explain this file"); + assert.deepEqual(splitProjectDetailAgentContext(content), { + context: payload.trim(), + message: "Explain this file", + }); +}); + +test("leaves ordinary messages unchanged without inventing context", () => { + assert.deepEqual(splitProjectDetailAgentContext("A normal message"), { + context: null, + message: "A normal message", + }); +}); + +test("splits only the final appended context marker", () => { + const userMessage = + "Discuss this literal example:\n---\nCurrent Buzz project page:\nnot appended"; + const payload = projectDetailAgentContextBlock( + buildProjectDetailAgentContext(base), + ); + assert.deepEqual(splitProjectDetailAgentContext(`${userMessage}${payload}`), { + context: payload.trim(), + message: userMessage, + }); +}); + +test("splits workspace repository context for the shared conversation view", () => { + const payload = + '\n---\nWorkspace repositories:\n- "Buzz" (address: "owner:buzz")'; + assert.deepEqual( + splitProjectDetailAgentContext(`Compare the repos${payload}`), + { + context: payload.trim(), + message: "Compare the repos", + }, + ); }); diff --git a/desktop/src/features/projects/lib/projectDetailAgentContext.ts b/desktop/src/features/projects/lib/projectDetailAgentContext.ts index 0194bad3e1..9714215700 100644 --- a/desktop/src/features/projects/lib/projectDetailAgentContext.ts +++ b/desktop/src/features/projects/lib/projectDetailAgentContext.ts @@ -5,6 +5,12 @@ import { } from "./projectSelection.ts"; const PROJECT_PAGE_CONTEXT_MARKER = "Current Buzz project page:"; +/** Marker for the repository set appended by the full Projects agent page. */ +export const PROJECT_WORKSPACE_CONTEXT_MARKER = "Workspace repositories:"; +const PROJECT_AGENT_CONTEXT_MARKERS = [ + PROJECT_PAGE_CONTEXT_MARKER, + PROJECT_WORKSPACE_CONTEXT_MARKER, +]; const MAX_OVERVIEW_CONTEXT_ITEMS = 200; const MAX_OVERVIEW_CONTEXT_FIELD_LENGTH = 180; const MAX_SELECTION_CONTEXT_ITEMS = 100; @@ -323,8 +329,24 @@ function overviewContextField(value: string | null | undefined) { return normalizedPromptValue(value, MAX_OVERVIEW_CONTEXT_FIELD_LENGTH); } -export function stripProjectDetailAgentContext(content: string) { - const markerIndex = content.indexOf(`---\n${PROJECT_PAGE_CONTEXT_MARKER}`); - if (markerIndex === -1) return content; - return content.slice(0, markerIndex).replace(/\n+$/, ""); +export function splitProjectDetailAgentContext(content: string): { + context: string | null; + message: string; +} { + const markerIndex = Math.max( + ...PROJECT_AGENT_CONTEXT_MARKERS.map((marker) => + content.lastIndexOf(`---\n${marker}`), + ), + ); + if (markerIndex === -1) { + return { context: null, message: content }; + } + return { + context: content.slice(markerIndex).trim(), + message: content.slice(0, markerIndex).replace(/\n+$/, ""), + }; +} + +export function stripProjectDetailAgentContext(content: string) { + return splitProjectDetailAgentContext(content).message; } diff --git a/desktop/src/features/projects/lib/projectReviewDisplay.test.mjs b/desktop/src/features/projects/lib/projectReviewDisplay.test.mjs new file mode 100644 index 0000000000..2f89decab4 --- /dev/null +++ b/desktop/src/features/projects/lib/projectReviewDisplay.test.mjs @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + currentPullRequestForSelection, + projectReviewFilesChangedBody, + retainLatestByKey, + reviewDiffWorkspaceBranch, + shouldReplaceRetainedPullRequest, +} from "./projectReviewDisplay.ts"; + +test("retainLatestByKey keeps the previous value when shouldReplace is false", () => { + const cache = { current: { key: "pr-1", value: { files: [1] } } }; + + const retained = retainLatestByKey( + cache, + "pr-1", + { files: [] }, + (next, previous) => + next.files.length > 0 ? true : previous.files.length === 0, + ); + + assert.deepEqual(retained, { files: [1] }); + assert.deepEqual(cache.current.value, { files: [1] }); +}); + +test("retainLatestByKey takes a new key immediately", () => { + const cache = { current: { key: "pr-1", value: { files: [1] } } }; + + const retained = retainLatestByKey( + cache, + "pr-2", + { files: [] }, + (next) => next.files.length > 0, + ); + + assert.deepEqual(retained, { files: [] }); +}); + +test("an explicit selected review does not fall back to another identity", () => { + const selected = { id: "pr-a" }; + const branchReview = { id: "pr-branch" }; + + assert.equal( + currentPullRequestForSelection({ + fallback: branchReview, + pullRequests: [selected, branchReview], + selectedPullRequestId: "pr-a", + }), + selected, + ); + assert.equal( + currentPullRequestForSelection({ + fallback: branchReview, + pullRequests: [branchReview], + selectedPullRequestId: "pr-a", + }), + null, + ); + assert.equal( + currentPullRequestForSelection({ + fallback: branchReview, + pullRequests: [branchReview], + selectedPullRequestId: null, + }), + branchReview, + ); +}); + +test("retained review identity stays aligned with the diff-query identity across fetch phases", () => { + const reviewA = { id: "pr-a" }; + const renderedCache = { current: { key: "repo:pr-a", value: reviewA } }; + const diffQueryCache = { current: { key: "repo:pr-a", value: reviewA } }; + + const renderedDuringFetch = retainLatestByKey( + renderedCache, + "repo:pr-a", + currentPullRequestForSelection({ + fallback: { id: "pr-branch" }, + pullRequests: [], + selectedPullRequestId: "pr-a", + }), + (next) => shouldReplaceRetainedPullRequest(next, true), + ); + const diffDuringFetch = retainLatestByKey( + diffQueryCache, + "repo:pr-a", + currentPullRequestForSelection({ + fallback: { id: "pr-branch" }, + pullRequests: [], + selectedPullRequestId: "pr-a", + }), + (next) => shouldReplaceRetainedPullRequest(next, true), + ); + assert.equal(renderedDuringFetch, reviewA); + assert.equal(diffDuringFetch, reviewA); + assert.equal(renderedDuringFetch.id, diffDuringFetch.id); + + const renderedAfterComplete = retainLatestByKey( + renderedCache, + "repo:pr-a", + currentPullRequestForSelection({ + fallback: { id: "pr-branch" }, + pullRequests: [], + selectedPullRequestId: "pr-a", + }), + (next) => shouldReplaceRetainedPullRequest(next, false), + ); + const diffAfterComplete = retainLatestByKey( + diffQueryCache, + "repo:pr-a", + currentPullRequestForSelection({ + fallback: { id: "pr-branch" }, + pullRequests: [], + selectedPullRequestId: "pr-a", + }), + (next) => shouldReplaceRetainedPullRequest(next, false), + ); + assert.equal(renderedAfterComplete, null); + assert.equal(diffAfterComplete, null); +}); + +test("review files stay mounted when a populated diff races an unavailable snapshot", () => { + assert.equal( + projectReviewFilesChangedBody({ + hasPopulatedDiff: true, + hasSelectedPullRequest: true, + repositoryUnavailable: true, + }), + "files", + ); +}); + +test("review files can show unavailable before a diff exists", () => { + assert.equal( + projectReviewFilesChangedBody({ + hasPopulatedDiff: false, + hasSelectedPullRequest: true, + repositoryUnavailable: true, + }), + "unavailable", + ); +}); + +test("review files render the panel for a selected review when the repo is available", () => { + assert.equal( + projectReviewFilesChangedBody({ + hasPopulatedDiff: false, + hasSelectedPullRequest: true, + repositoryUnavailable: false, + }), + "files", + ); +}); + +test("review diffs stay on the target branch, not the head or picker branch", () => { + assert.equal( + reviewDiffWorkspaceBranch({ + activeBranch: "variation/bees", + defaultBranch: "main", + pullRequest: { targetBranch: "main" }, + }), + "main", + ); + assert.equal( + reviewDiffWorkspaceBranch({ + activeBranch: "variation/bees", + defaultBranch: "main", + pullRequest: { targetBranch: null }, + }), + "main", + ); + assert.equal( + reviewDiffWorkspaceBranch({ + activeBranch: "variation/bees", + defaultBranch: "main", + pullRequest: null, + }), + "variation/bees", + ); +}); diff --git a/desktop/src/features/projects/lib/projectReviewDisplay.ts b/desktop/src/features/projects/lib/projectReviewDisplay.ts new file mode 100644 index 0000000000..d7ba721238 --- /dev/null +++ b/desktop/src/features/projects/lib/projectReviewDisplay.ts @@ -0,0 +1,101 @@ +type RetainLatestByKeyCache = { + current: { + key: string; + value: T; + }; +}; + +/** + * Keep the latest accepted value for a stable key across transient empties. + * A new key always takes `value` immediately (real navigation). + */ +export function retainLatestByKey( + cache: RetainLatestByKeyCache, + key: string, + value: T, + shouldReplace: (next: T, previous: T) => boolean, +): T { + if (cache.current.key !== key) { + cache.current = { key, value }; + return value; + } + if (shouldReplace(value, cache.current.value)) { + cache.current.value = value; + } + return cache.current.value; +} + +/** + * Keep a selected review through transient empty refetches. Once the fetch + * is idle, accept the completed result — including null — so the rendered + * identity can match the diff-query identity. + */ +export function shouldReplaceRetainedPullRequest( + next: unknown, + isFetching: boolean, +): boolean { + return Boolean(next) || !isFetching; +} + +/** + * Resolve the current review for a selection. An explicit ID that is missing + * from the list is `null` so a completed refetch can clear it; pass + * `fallback` only when no ID is selected (branch auto-select). + */ +export function currentPullRequestForSelection({ + fallback = null, + pullRequests, + selectedPullRequestId, +}: { + fallback?: T | null; + pullRequests: readonly T[] | undefined; + selectedPullRequestId: string | null; +}): T | null { + if (selectedPullRequestId) { + return ( + pullRequests?.find((item) => item.id === selectedPullRequestId) ?? null + ); + } + return fallback; +} + +/** + * Which body to render under a review's Files changed section. + * A populated diff must keep the files panel mounted even when the repository + * snapshot briefly looks unavailable — swapping in the unavailable placeholder + * is the files-section flicker. + */ +export function projectReviewFilesChangedBody({ + hasPopulatedDiff, + hasSelectedPullRequest, + repositoryUnavailable, +}: { + hasPopulatedDiff: boolean; + hasSelectedPullRequest: boolean; + repositoryUnavailable: boolean; +}): "files" | "unavailable" | null { + if (hasSelectedPullRequest && (hasPopulatedDiff || !repositoryUnavailable)) { + return "files"; + } + if (repositoryUnavailable) return "unavailable"; + return null; +} + +/** + * Workspace branch used to fetch a review diff. + * A selected review is `target...head`; do not key that query on the head + * branch or the workspace picker, or Files changed will swap with the + * default-branch snapshot. + */ +export function reviewDiffWorkspaceBranch({ + activeBranch, + defaultBranch, + pullRequest, +}: { + activeBranch: string | null | undefined; + defaultBranch: string | null | undefined; + pullRequest: { targetBranch: string | null } | null | undefined; +}): string | null | undefined { + if (!pullRequest) return activeBranch; + return pullRequest.targetBranch || defaultBranch || activeBranch; +} diff --git a/desktop/src/features/projects/ui/AgentContextPayloadPreview.test.mjs b/desktop/src/features/projects/ui/AgentContextPayloadPreview.test.mjs index 749d5eb501..6229c77538 100644 --- a/desktop/src/features/projects/ui/AgentContextPayloadPreview.test.mjs +++ b/desktop/src/features/projects/ui/AgentContextPayloadPreview.test.mjs @@ -33,7 +33,7 @@ afterEach(async () => { after(() => dom.window.close()); -async function renderPreview(payload) { +async function renderPreview(payload, options = {}) { const { createElement } = await import("react"); const { render } = await import("@testing-library/react"); const { AgentContextPayloadPreview } = await import( @@ -41,8 +41,9 @@ async function renderPreview(payload) { ); return render( createElement(AgentContextPayloadPreview, { + iconOnly: options.iconOnly, payload, - triggerLabel: "Context", + triggerLabel: options.triggerLabel ?? "Context", }), ); } @@ -83,6 +84,24 @@ test("discloses the exact appended payload before send, adversarial metadata inc assert.equal(screen.queryByTestId("agent-context-preview"), null); }); +test("supports a subtle icon-only disclosure without losing its accessible name", async () => { + const { fireEvent, screen } = await import("@testing-library/react"); + await renderPreview("Exact context", { + iconOnly: true, + triggerLabel: "Preview message context", + }); + + const trigger = screen.getByRole("button", { + name: "Preview message context", + }); + assert.equal(trigger.textContent?.trim(), ""); + fireEvent.click(trigger); + assert.equal( + screen.getByTestId("agent-context-preview-payload").textContent, + "Exact context", + ); +}); + test("renders nothing when there is no payload to append", async () => { const { screen } = await import("@testing-library/react"); await renderPreview(""); diff --git a/desktop/src/features/projects/ui/AgentContextPayloadPreview.tsx b/desktop/src/features/projects/ui/AgentContextPayloadPreview.tsx index d6db47b868..550cadd86a 100644 --- a/desktop/src/features/projects/ui/AgentContextPayloadPreview.tsx +++ b/desktop/src/features/projects/ui/AgentContextPayloadPreview.tsx @@ -1,6 +1,7 @@ import { Info } from "lucide-react"; import * as React from "react"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; /** @@ -13,9 +14,11 @@ import { Button } from "@/shared/ui/button"; * inspects here is byte-identical to what gets signed under their key. */ export function AgentContextPayloadPreview({ + iconOnly = false, payload, triggerLabel, }: { + iconOnly?: boolean; payload: string; triggerLabel: string; }) { @@ -25,8 +28,14 @@ export function AgentContextPayloadPreview({ return (
{open ? (
{ + const byChannel = new Map(); + for (const hit of hits) { + if (hit.channelId && !byChannel.has(hit.channelId)) { + byChannel.set(hit.channelId, hit); + } + } + return byChannel; + }, [hits]); +} + +/** + * Shared row-click behavior: land on the latest matching message — in the + * side conversation panel when one is mounted — instead of jumping straight + * to the channel. Forum content opens in place (the panel renders chat + * threads only), and channels with no quotable hit fall back to plain + * channel navigation. + */ +function openDiscussionHit({ + channelId, + goChannel, + latestHit, + openSearchHit, + panel, +}: { + channelId: string; + goChannel: (channelId: string) => unknown; + latestHit: SearchHit | undefined; + openSearchHit: (hit: SearchHit) => unknown; + panel: { openConversation: (hit: SearchHit) => void } | null; +}) { + if (!latestHit) { + void goChannel(channelId); + return; + } + const opensForum = + latestHit.kind === KIND_FORUM_POST || latestHit.kind === KIND_FORUM_COMMENT; + if (!panel || opensForum) { + void openSearchHit(latestHit); + return; + } + panel.openConversation(latestHit); +} + /** Channel display name, preferring the hit's name, then bounded metadata, * then a short id so inaccessible/renamed channels still render something. */ function useChannelNameLookup(channelIds: readonly string[]) { @@ -141,19 +188,10 @@ export function DiscussedInChannels({ { enabled: visible.length > 0 }, ); const profiles = profilesQuery.data?.profiles; - // Hits are sorted newest first, so the first hit per channel is the one a - // click should land on (and the one worth quoting). The origin channel has - // no such hit: the `h` tag proves only the channel, so its row navigates - // to the channel without claiming any particular message. - const latestHitByChannel = React.useMemo(() => { - const byChannel = new Map(); - for (const hit of hits) { - if (hit.channelId && !byChannel.has(hit.channelId)) { - byChannel.set(hit.channelId, hit); - } - } - return byChannel; - }, [hits]); + // The origin channel has no quotable hit: the `h` tag proves only the + // channel, so its row navigates to the channel without claiming any + // particular message. + const latestHitByChannel = useLatestHitByChannel(hits); if (channels.length === 0) return null; const hiddenCount = channels.length - visible.length; @@ -173,21 +211,14 @@ export function DiscussedInChannels({ {visible.map((channel) => { const latestHit = latestHitByChannel.get(channel.id); const name = channelName(channel.id, channel.name); - const opensForum = - latestHit != null && - (latestHit.kind === KIND_FORUM_POST || - latestHit.kind === KIND_FORUM_COMMENT); - const openConversation = () => { - if (!latestHit) { - void goChannel(channel.id); - return; - } - if (!projectConversationPanel || opensForum) { - void openSearchHit(latestHit); - return; - } - projectConversationPanel.openConversation(latestHit); - }; + const openConversation = () => + openDiscussionHit({ + channelId: channel.id, + goChannel, + latestHit, + openSearchHit, + panel: projectConversationPanel, + }); return (
@@ -336,7 +367,9 @@ function DiscussionNameList({ /** * Full-width channel list for the workspace "Channels" tab: every channel * where the repository (or its PRs/issues) is linked in chat, with the - * people who discussed it there. + * people who discussed it there. Clicking a row opens the latest matching + * conversation in the side panel (whose header still jumps to the channel) + * rather than leaving the project view. */ export function DiscussionChannelsPanel({ query, @@ -345,13 +378,17 @@ export function DiscussionChannelsPanel({ query: string; repositoryName: string; }) { - const { channels, isLoading, isTruncated } = useDiscussionChannels(query); - const { goChannel } = useAppNavigation(); + const { channels, hits, isLoading, isTruncated } = + useDiscussionChannels(query); + const { goChannel, openSearchHit } = useAppNavigation(); + const projectConversationPanel = useProjectConversationPanel(); + const latestHitByChannel = useLatestHitByChannel(hits); const channelIds = React.useMemo( () => channels.map((channel) => channel.id), [channels], ); const channelName = useChannelNameLookup(channelIds); + const profilesQuery = useUsersBatchQuery( channels.flatMap((channel) => channel.participants), { enabled: channels.length > 0 }, @@ -363,7 +400,10 @@ export function DiscussionChannelsPanel({ } if (channels.length === 0) { return ( -

+

No channels reference this repository yet. Paste its link (or a review or task link) in a channel and it will show up here.

@@ -379,10 +419,11 @@ export function DiscussionChannelsPanel({ ); return ( -
+
    {channels.map((channel) => { const name = channelName(channel.id, channel.name); + const latestHit = latestHitByChannel.get(channel.id); return (
  • } - onClick={() => void goChannel(channel.id)} + onClick={() => + openDiscussionHit({ + channelId: channel.id, + goChannel, + latestHit, + openSearchHit, + panel: projectConversationPanel, + }) + } people={channel.participants} peopleTestId="project-channel-participants" profiles={profiles} @@ -411,7 +460,11 @@ export function DiscussionChannelsPanel({ }} testId="project-channel-row" title={`#${name}`} - titleAttr={`Open #${name}`} + titleAttr={ + latestHit + ? `Open the latest conversation in #${name}` + : `Open #${name}` + } />
  • ); diff --git a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx index 4da5123814..7413044727 100644 --- a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx +++ b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx @@ -267,7 +267,9 @@ export function IssueAssigneesRow({ {canSelfAssign && viewer ? ( + {open ? ( +
    + {payload} +
    + ) : null} +
+ ); + }, +); diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index b8c3ffd273..68e067e0d1 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -20,10 +20,8 @@ import type { ProjectActivitySummary, } from "@/features/projects/hooks"; import { - formatExactTimestamp, getProjectUpdatedAt, listRowDescription, - relativeTime, } from "@/features/projects/lib/projectsViewHelpers"; import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability"; import { projectShareLink } from "@/features/projects/lib/projectShareLinks"; @@ -55,39 +53,6 @@ import { ProjectEntityListRow } from "./ProjectEntityListRow"; import { PROJECT_GRID_CARD_BODY_CLASS } from "./projectGridCardStyles"; import { ProjectListRowMenu } from "./ProjectListRowMenu"; -function ProjectUpdatedLabel({ - profiles, - project, - summary, -}: { - profiles?: UserProfileLookup; - project: Project; - summary: ProjectActivitySummary | undefined; -}) { - const updatedAt = getProjectUpdatedAt(project, summary); - const latestCommit = summary?.latestCommit; - const authorLabel = latestCommit?.author - ? resolveUserLabel({ profiles, pubkey: latestCommit.author }) - : null; - - return ( - - - - {relativeTime(updatedAt)} - - - - {latestCommit - ? `${latestCommit.title || latestCommit.commit.slice(0, 7)}${ - authorLabel ? ` · ${authorLabel}` : "" - } · ${formatExactTimestamp(latestCommit.createdAt)}` - : `Created ${formatExactTimestamp(project.createdAt)}`} - - - ); -} - export function ProjectPeopleStack({ pubkeys, profiles, @@ -540,12 +505,7 @@ export function ProjectGridCard({
-
- +
{repositoryCount} } + affiliationClassName="w-auto" affiliationTestId="projects-row-context" affiliationTitle={`${repositoryCount} ${ repositoryCount === 1 ? "repository" : "repositories" diff --git a/desktop/src/features/projects/ui/ProjectCommitCopyButton.tsx b/desktop/src/features/projects/ui/ProjectCommitCopyButton.tsx index 0930146e2d..faadaf5d2c 100644 --- a/desktop/src/features/projects/ui/ProjectCommitCopyButton.tsx +++ b/desktop/src/features/projects/ui/ProjectCommitCopyButton.tsx @@ -15,12 +15,16 @@ export function CopyTextButton({ text: string; }) { const [copied, setCopied] = React.useState(false); - const handleCopy = React.useCallback(() => { - void writeTextToClipboard(text).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2_000); - }); - }, [text]); + const handleCopy = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + void writeTextToClipboard(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2_000); + }); + }, + [text], + ); return ( - {open ?
{children}
: null} + {open ? ( +
{children}
+ ) : null} ); } diff --git a/desktop/src/features/projects/ui/ProjectEntityListRow.tsx b/desktop/src/features/projects/ui/ProjectEntityListRow.tsx index 10cd1f87a4..d0769e31e5 100644 --- a/desktop/src/features/projects/ui/ProjectEntityListRow.tsx +++ b/desktop/src/features/projects/ui/ProjectEntityListRow.tsx @@ -131,6 +131,7 @@ export function ProjectEntitySelectControl({ export function ProjectEntityListRow({ affiliation, + affiliationClassName, affiliationTestId, affiliationTitle, beforeDate, @@ -158,6 +159,7 @@ export function ProjectEntityListRow({ trailing, }: { affiliation?: React.ReactNode; + affiliationClassName?: string; affiliationTestId?: string; affiliationTitle?: string; beforeDate?: React.ReactNode; @@ -292,7 +294,10 @@ export function ProjectEntityListRow({ ) : null} {affiliation ? ( {count != null ? ( - + {count} {countSuffix} diff --git a/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx index 105d20f10e..72884a33ed 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx @@ -755,8 +755,10 @@ export function ProjectPullRequestFilesChangedPanel({ } export function ProjectDiffFilesPanel({ + className, error, diff, + fileTreeClassName, isLoading, embedded = false, focusedAnchor, @@ -764,6 +766,10 @@ export function ProjectDiffFilesPanel({ inlineComments, subjectLabel, }: { + /** Extra classes for the file-tree/diff grid container. */ + className?: string; + /** Overrides the file tree's default `max-h-96` cap, e.g. for full-height layouts. */ + fileTreeClassName?: string; error: unknown; diff: ProjectRepoDiff | null | undefined; isLoading: boolean; @@ -814,11 +820,11 @@ export function ProjectDiffFilesPanel({ } }, [filteredFiles, selectedPath]); - if (isLoading) { + if (isLoading && !diff) { return ; } - if (error) { + if (error && !diff) { const message = errorMessage(error); return (
@@ -876,7 +883,12 @@ export function ProjectDiffFilesPanel({ />
-