diff --git a/Cargo.lock b/Cargo.lock index 83d2b9e3e3..f7c42e625a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1180,7 +1180,7 @@ dependencies = [ [[package]] name = "buzz-relay" -version = "0.2.0" +version = "0.2.1" dependencies = [ "anyhow", "async-compression", diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 7872a33321..4a1a0c5cfe 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -2204,8 +2204,33 @@ where } } } - return serde_json::from_slice(&buf) - .map_err(|e| PostError::Agent(AgentError::Llm(format!("json: {e}")))); + // A 2xx body that fails to parse (e.g. a provider flushing a + // truncated JSON document and closing the stream cleanly) is a + // transient upstream fault, not a request problem: retry it like a + // 5xx. Safe to re-send — a malformed body produced no parsed + // response, so no tool call was ever extracted from it, and the + // retried request is the identical completion POST captured in + // `body_bytes` at function entry. + match serde_json::from_slice(&buf) { + Ok(value) => return Ok(value), + Err(e) => { + if attempt + 1 < MAX_RETRIES { + tracing::warn!( + attempt = attempt + 1, + max_attempts = MAX_RETRIES, + error = %e, + "llm: malformed response body, retrying" + ); + backoff_with_jitter(attempt).await; + continue; + } + return Err(PostError::Agent(terminal_llm_error( + call_start.elapsed(), + attempt + 1, + &format!("json: {e}"), + ))); + } + } } // Unreachable in practice: every iteration either returns or continues. // A fallthrough here would mean MAX_RETRIES was 0, which is rejected at @@ -2611,7 +2636,31 @@ async fn openrouter_post( } } } - return serde_json::from_slice(&buf).map_err(|e| AgentError::Llm(format!("json: {e}"))); + // Same malformed-body retry as the shared `post()` terminal: a + // truncated 2xx JSON body is transient upstream trouble, and + // re-sending is provably tool-safe — nothing was parsed, so no tool + // call could have been extracted, and the retry re-uses the + // identical `body_bytes` completion request. + match serde_json::from_slice(&buf) { + Ok(value) => return Ok(value), + Err(e) => { + if attempt + 1 < MAX_RETRIES { + tracing::warn!( + attempt = attempt + 1, + max_attempts = MAX_RETRIES, + error = %e, + "llm: openrouter malformed response body, retrying" + ); + backoff_with_jitter(attempt).await; + continue; + } + return Err(terminal_llm_error( + call_start.elapsed(), + attempt + 1, + &format!("json: {e}"), + )); + } + } } Err(terminal_llm_error( call_start.elapsed(), @@ -4814,6 +4863,237 @@ mod tests { ); } + /// Regression (write-compressor, tb21-twins-1): a provider returning + /// HTTP 200 with a *truncated* JSON body (cleanly closed, correct + /// framing, unparseable content) previously surfaced as a terminal + /// `AgentError::Llm("json: EOF while parsing a value ...")` on the very + /// first attempt, killing the agent turn. A malformed body is transient + /// upstream trouble and must be retried like a 5xx. Re-sending is + /// tool-safe: nothing was parsed, so no tool call was extracted from the + /// bad body, and the retry replays the identical completion request. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn post_retries_malformed_json_body_and_succeeds() { + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/v1/x", listener.local_addr().unwrap()); + let accepts = Arc::new(AtomicU32::new(0)); + let accepts_srv = accepts.clone(); + + tokio::spawn(async move { + loop { + let (mut sock, _) = match listener.accept().await { + Ok(p) => p, + Err(_) => return, + }; + let n = accepts_srv.fetch_add(1, Ordering::SeqCst); + let mut buf = Vec::new(); + let mut tmp = [0u8; 4096]; + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + match sock.read(&mut tmp).await { + Ok(0) | Err(_) => return, + Ok(k) => buf.extend_from_slice(&tmp[..k]), + } + } + if n == 0 { + // First attempt: 200 OK with a truncated JSON document. + // Content-Length matches the bytes actually sent, so the + // body read completes cleanly — the fault is purely that + // the JSON is cut off mid-value. + let body = "{\"choices\":[{\"mess"; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.shutdown().await; + continue; + } + // Subsequent attempts: complete valid JSON. + let body = "{\"ok\":true}"; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.shutdown().await; + } + }); + + let client = Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let out = post( + &client, + &url, + &serde_json::json!({}), + false, + Duration::from_secs(5), + |b| b, + ) + .await + .expect("post should succeed after retrying the malformed body"); + assert_eq!(out, serde_json::json!({ "ok": true })); + assert_eq!( + accepts.load(Ordering::SeqCst), + 2, + "server must see exactly 2 attempts (malformed body retried once)" + ); + } + + /// A persistently malformed 200 body exhausts MAX_RETRIES and surfaces + /// the terminal error with the `json:` detail plus cumulative + /// duration/attempt count — never an early first-attempt death. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn post_exhausts_retries_on_persistent_malformed_json() { + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/v1/x", listener.local_addr().unwrap()); + let accepts = Arc::new(AtomicU32::new(0)); + let accepts_srv = accepts.clone(); + + tokio::spawn(async move { + loop { + let (mut sock, _) = match listener.accept().await { + Ok(p) => p, + Err(_) => return, + }; + accepts_srv.fetch_add(1, Ordering::SeqCst); + let mut buf = Vec::new(); + let mut tmp = [0u8; 4096]; + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + match sock.read(&mut tmp).await { + Ok(0) | Err(_) => return, + Ok(k) => buf.extend_from_slice(&tmp[..k]), + } + } + let body = "{\"choices\":[{\"mess"; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.shutdown().await; + } + }); + + let client = Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let err = post( + &client, + &url, + &serde_json::json!({}), + false, + Duration::from_secs(5), + |b| b, + ) + .await + .unwrap_err(); + match &err { + PostError::Agent(AgentError::Llm(msg)) => { + assert!( + msg.contains("json:"), + "expected the json parse detail, got: {msg}" + ); + assert!( + msg.contains("cumulative") && msg.contains("3 attempts"), + "expected cumulative duration + exact attempt count, got: {msg}" + ); + } + other => panic!("expected PostError::Agent(AgentError::Llm), got: {other:?}"), + } + assert_eq!( + accepts.load(Ordering::SeqCst), + MAX_RETRIES, + "server must see exactly MAX_RETRIES attempts — malformed bodies must be retried" + ); + } + + /// Same regression coverage for `openrouter_post`, which carries its own + /// retry loop: a truncated 200 body on the first attempt is retried and + /// the call succeeds on the second. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_post_retries_malformed_json_body_and_succeeds() { + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/v1/x", listener.local_addr().unwrap()); + let accepts = Arc::new(AtomicU32::new(0)); + let accepts_srv = accepts.clone(); + + tokio::spawn(async move { + loop { + let (mut sock, _) = match listener.accept().await { + Ok(p) => p, + Err(_) => return, + }; + let n = accepts_srv.fetch_add(1, Ordering::SeqCst); + let mut buf = Vec::new(); + let mut tmp = [0u8; 4096]; + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + match sock.read(&mut tmp).await { + Ok(0) | Err(_) => return, + Ok(k) => buf.extend_from_slice(&tmp[..k]), + } + } + if n == 0 { + let body = "{\"choices\":[{\"mess"; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.shutdown().await; + continue; + } + let body = r#"{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}"#; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.shutdown().await; + } + }); + + let client = Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let out = openrouter_post(&client, &url, &json!({}), "key", Duration::from_secs(5)) + .await + .expect("openrouter_post should succeed after retrying the malformed body"); + assert!(out.is_object(), "expected a JSON object: {out:?}"); + assert_eq!( + accepts.load(Ordering::SeqCst), + 2, + "server must see exactly 2 attempts (malformed body retried once)" + ); + } + /// A body-read timeout on the first attempt triggers a retry under an /// escalated budget, and the call succeeds on the second attempt. /// diff --git a/crates/buzz-relay/CHANGELOG.md b/crates/buzz-relay/CHANGELOG.md index c0d6ab9ccd..7ee7e9a303 100644 --- a/crates/buzz-relay/CHANGELOG.md +++ b/crates/buzz-relay/CHANGELOG.md @@ -1,5 +1,114 @@ # Changelog +## relay-v0.2.1 + +- fix(sdk): preserve self-mention p tags in message and forum event builders ([#4975](https://github.com/block/buzz/pull/4975)) ([`78c87ae20e`](https://github.com/block/buzz/commit/78c87ae20e182fffdd99744d6c9ff99df82b159c)) +- feat(desktop): adding rich link previews to messages ([#3818](https://github.com/block/buzz/pull/3818)) ([`1922d49cb2`](https://github.com/block/buzz/commit/1922d49cb200a3382a91ec253f530b44dfda5f55)) +- feat(relay): accept kind:30179 private managed-agent events at ingest ([#5133](https://github.com/block/buzz/pull/5133)) ([`ad923353a2`](https://github.com/block/buzz/commit/ad923353a24b784df13a7c88757d6b24ebe36299)) +- fix(media): require authenticated reads ([#4610](https://github.com/block/buzz/pull/4610)) ([`769ac70b74`](https://github.com/block/buzz/commit/769ac70b741e3ad6809bff14eba29d3dd2cbd318)) +- feat(identity): recover desktop identity from a signed-in phone ([#4845](https://github.com/block/buzz/pull/4845)) ([`6eb65919f1`](https://github.com/block/buzz/commit/6eb65919f1eabd46b3850c15eefab31092dd500b)) +- ci: prove the relay-driven mesh lifecycle — discover, join, infer, deny — with real nodes ([#3862](https://github.com/block/buzz/pull/3862)) ([`38bf642fcf`](https://github.com/block/buzz/commit/38bf642fcfa7a9fc1e06d6cf87d66ae94da29341)) +- relay: fuzz WebSocket 1012 restart-close timing on graceful drain (BUZZ_DRAIN_JITTER_MS) ([#4542](https://github.com/block/buzz/pull/4542)) ([`e14fff74d0`](https://github.com/block/buzz/commit/e14fff74d00623acd30945eec5be366e25b0cf09)) +- fix(reactions): support max-length custom emoji ([#3833](https://github.com/block/buzz/pull/3833)) ([`2ea9385015`](https://github.com/block/buzz/commit/2ea9385015fb922de2adf0a53e86fc5a21d07b90)) +- fix(channels): restrict private-channel invitations ([#4612](https://github.com/block/buzz/pull/4612)) ([`efe1893dd3`](https://github.com/block/buzz/commit/efe1893dd372cfb92ed2e8a3ada2ed7b62c9477a)) +- fix(workflow): bind trigger author to the signed event ([#4607](https://github.com/block/buzz/pull/4607)) ([`885bed35ee`](https://github.com/block/buzz/commit/885bed35eee3f933c48d333c8979fdbc038e98b9)) +- fix(git): revoke access for banned relay members ([#4608](https://github.com/block/buzz/pull/4608)) ([`997b8caaa4`](https://github.com/block/buzz/commit/997b8caaa4c9e5af69dd8a496b4995d09a69f694)) +- Define private managed agent wire protocol ([#4593](https://github.com/block/buzz/pull/4593)) ([`067c085f37`](https://github.com/block/buzz/commit/067c085f37d9dcb2f598b0e2a6b6653903364783)) +- perf(relay): index channel-id lookups and skip trace-only reads ([#4647](https://github.com/block/buzz/pull/4647)) ([`bc9e6528a7`](https://github.com/block/buzz/commit/bc9e6528a7ba6007c5a25f6a0aca9c05d72e9d2c)) +- Polish mobile inbox and media flows ([#4512](https://github.com/block/buzz/pull/4512)) ([`feccf4eabc`](https://github.com/block/buzz/commit/feccf4eabc23fdba94ce3537a194357ed17b197c)) +- fix(git): allow deleting the default branch ([#4297](https://github.com/block/buzz/pull/4297)) ([`fc598f5f8d`](https://github.com/block/buzz/commit/fc598f5f8d70728d11d0712b9fa8e3acc44ea4c3)) +- feat(projects): add buzz projects CLI commands (NIP-MP kind:30621) ([#4020](https://github.com/block/buzz/pull/4020)) ([`b7bb15122e`](https://github.com/block/buzz/commit/b7bb15122e8a2053b545dc2210afc167f6c7a626)) +- perf(relay): serve relay-membership checks from the read replica ([#4124](https://github.com/block/buzz/pull/4124)) ([`ac4fa13b8e`](https://github.com/block/buzz/commit/ac4fa13b8e4d947071d57deb6918dcf12bf74961)) +- fix(relay): allow open relays to set their NIP-11 workspace icon (kind:9033) ([#3998](https://github.com/block/buzz/pull/3998)) ([`5765fc74b7`](https://github.com/block/buzz/commit/5765fc74b77224f0207ddd4b41736a5ff18d333d)) +- feat(relay): accept kind:30621 multi-repo projects at ingest ([#3171](https://github.com/block/buzz/pull/3171)) ([`cb9701cd30`](https://github.com/block/buzz/commit/cb9701cd30fb344bf134585634a09007f3155bfb)) +- feat(relay): raise hosted community limit to five ([#3829](https://github.com/block/buzz/pull/3829)) ([`10d5a26414`](https://github.com/block/buzz/commit/10d5a26414dc90dc89fd27de74b21e105d4fa622)) +- fix(relay): align NIP-11 max_limit with REQ ceiling ([#3635](https://github.com/block/buzz/pull/3635)) ([`23f0c26b1c`](https://github.com/block/buzz/commit/23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9)) +- feat(relay): gate kind 30178 team-catalog reads behind the shared tag ([#3358](https://github.com/block/buzz/pull/3358)) ([`114d40d9d3`](https://github.com/block/buzz/commit/114d40d9d37f05eff83ee90347ed93fb3da512c5)) +- fix(db): isolate usage metrics advisory-lock test on scratch DB ([#3670](https://github.com/block/buzz/pull/3670)) ([`dba97eecd9`](https://github.com/block/buzz/commit/dba97eecd9d8659c9c816cd6666fa6d687b6bca1)) +- perf(presence): reduce heartbeat frequency ([#3783](https://github.com/block/buzz/pull/3783)) ([`bf139e8d0b`](https://github.com/block/buzz/commit/bf139e8d0bdba10df9a5adbf16843140e0a78a59)) +- feat(mesh): upgrade embedded mesh to v0.74 and harden shared compute (split 1/2 of #3467) ([#3741](https://github.com/block/buzz/pull/3741)) ([`4933672eb4`](https://github.com/block/buzz/commit/4933672eb4589e7208b312829ebddcd10dfa9dd3)) +- feat(replica): portable heartbeat-token fence with snapshot-local reader routing ([#3268](https://github.com/block/buzz/pull/3268)) ([`63496cc1d4`](https://github.com/block/buzz/commit/63496cc1d4c6f1b7c613801bdcc694169dcf391a)) +- fix(git): channel binding tooling + author remediation for unbound repos ([#3626](https://github.com/block/buzz/pull/3626)) ([`788b3c002b`](https://github.com/block/buzz/commit/788b3c002bd2509455444f57f8a03a054b4b496a)) +- feat: configure S3 URL addressing style ([#3400](https://github.com/block/buzz/pull/3400)) ([`7012d86d52`](https://github.com/block/buzz/commit/7012d86d52fd188b27c7beedeaa132d9c1f61fa8)) +- feat(tracing): correlate trace IDs in relay logs ([#3608](https://github.com/block/buzz/pull/3608)) ([`005b5b819a`](https://github.com/block/buzz/commit/005b5b819a98ce85d4d80cd81b258fb6f9b8d51e)) +- fix(relay): avoid subscription lock inversion ([#3413](https://github.com/block/buzz/pull/3413)) ([`22be8bb351`](https://github.com/block/buzz/commit/22be8bb35177e27efc2dca2534df9a8dd871eae0)) +- feat(cli): add users set-status command for NIP-38 profile status ([#3253](https://github.com/block/buzz/pull/3253)) ([`60158fce3e`](https://github.com/block/buzz/commit/60158fce3e670f11bb35d42627857ccaea50ff06)) +- feat(relay): make Postgres pool size configurable, default 50 ([#3191](https://github.com/block/buzz/pull/3191)) ([`2ce2d71cc3`](https://github.com/block/buzz/commit/2ce2d71cc38a9657eaf344c10e07f155b8a18615)) +- feat(tracing): add datastore tracing plumbing ([#2760](https://github.com/block/buzz/pull/2760)) ([`e94b9aeda0`](https://github.com/block/buzz/commit/e94b9aeda0b2272d36e3744e78680be69295b8b5)) +- feat(invites): add use-limited invite links ([#3141](https://github.com/block/buzz/pull/3141)) ([`d500c2d5cf`](https://github.com/block/buzz/commit/d500c2d5cf5d9aabe0ca4ebebfcafdbe5f5b7fd3)) +- feat(admin): show reported message content in report detail ([#3149](https://github.com/block/buzz/pull/3149)) ([`f069a85503`](https://github.com/block/buzz/commit/f069a8550373328babe4239ed614fcdf884721e2)) +- resolve findings ([#3150](https://github.com/block/buzz/pull/3150)) ([`9b0f744804`](https://github.com/block/buzz/commit/9b0f744804697b802f7afb88947194702765c78d)) +- Revert "fix(cli,relay): resolve agents by verified owner" ([#3168](https://github.com/block/buzz/pull/3168)) ([`a041e2d21e`](https://github.com/block/buzz/commit/a041e2d21e292a271fdfc26f0cdcdd0456f815c5)) +- fix(cli,relay): resolve agents by verified owner ([#2615](https://github.com/block/buzz/pull/2615)) ([`c3084b36d9`](https://github.com/block/buzz/commit/c3084b36d975259f2dfeee8edc9131b40a8bce83)) +- fix(security): enforce durable community ban on NIP-43 relay-admin kinds 9030-9033 ([#3128](https://github.com/block/buzz/pull/3128)) ([`e2e0079101`](https://github.com/block/buzz/commit/e2e007910114ddf7c5a4e93bb03f6afe13552e92)) +- fix(security): authorize kind:9000 role changes in both directions ([#3017](https://github.com/block/buzz/pull/3017)) ([`00ecf2cac7`](https://github.com/block/buzz/commit/00ecf2cac7544d986b4eb111ad0a8b1d7560791f)) +- feat(desktop): handle project work from Inbox ([#3117](https://github.com/block/buzz/pull/3117)) ([`c5c4f390b6`](https://github.com/block/buzz/commit/c5c4f390b6713256e2efb8394c59823ebad73db6)) +- feat(relay): make per-owner community limit configurable via BUZZ_MAX_COMMUNITIES_PER_OWNER ([#2599](https://github.com/block/buzz/pull/2599)) ([`2a051a404d`](https://github.com/block/buzz/commit/2a051a404dcde42dddbff2a0b33f717ffe9cf999)) +- feat(relay): add author-only-unless-shared read gate for kind 30175 ([#2768](https://github.com/block/buzz/pull/2768)) ([`ab3af82871`](https://github.com/block/buzz/commit/ab3af828714ab699dfc87644d234014987a4fe6b)) +- fix(core): block IPv6 transition SSRF targets ([#2801](https://github.com/block/buzz/pull/2801)) ([`c26bf5945d`](https://github.com/block/buzz/commit/c26bf5945d8f2ef19746a78e80a7c1dae2ef3db9)) +- fix(workflow): bypass system proxies for webhooks ([#2800](https://github.com/block/buzz/pull/2800)) ([`60a171b19e`](https://github.com/block/buzz/commit/60a171b19efd515d9213b535d52a2bcbec3ff2fe)) +- fix(audit): hash created_at at the precision Postgres stores ([#2638](https://github.com/block/buzz/pull/2638)) ([`264a56a226`](https://github.com/block/buzz/commit/264a56a2260ac87350bfe1f5d3ec3d89615eb47c)) +- feat(desktop): make pull request reviews actionable ([#2510](https://github.com/block/buzz/pull/2510)) ([`9081ab0ec9`](https://github.com/block/buzz/commit/9081ab0ec9c5d91548c7f5ff52eba6cca4788dd0)) +- fix(relay): decompress gzip-encoded git smart-HTTP request bodies ([#2670](https://github.com/block/buzz/pull/2670)) ([`5ca36e7b91`](https://github.com/block/buzz/commit/5ca36e7b919097733868764d8e0073e99c3206c3)) +- fix(sharing): preserve agent/team snapshot tEXt chunks through media sanitization ([#2438](https://github.com/block/buzz/pull/2438)) ([`b096b0a15a`](https://github.com/block/buzz/commit/b096b0a15af4c4566365c5b1efe7f39b700222ed)) +- fix(relay): send 1012 restart close to all clients on graceful drain ([#2575](https://github.com/block/buzz/pull/2575)) ([`1911c69aa2`](https://github.com/block/buzz/commit/1911c69aa2912c1408bd6b21759b657458fb43af)) +- fix(media): sanitize animated image uploads ([#2524](https://github.com/block/buzz/pull/2524)) ([`8f8f5fa5a4`](https://github.com/block/buzz/commit/8f8f5fa5a4b2463cdc6c2a527acb7086150cdaae)) +- fix(channels): strip leading hash prefixes from names ([#2250](https://github.com/block/buzz/pull/2250)) ([`d0ab3fdb05`](https://github.com/block/buzz/commit/d0ab3fdb054e0cfedbf21e4c5143ad6c671c10cc)) +- feat(relay): make Redis pool size configurable, default 16 ([#2521](https://github.com/block/buzz/pull/2521)) ([`bcc3e13069`](https://github.com/block/buzz/commit/bcc3e1306946528102bb26be9a7c41299e2f8e00)) +- feat(desktop+acp): spawn a harness per (agent, community) pair at GUI startup — warm sockets, lazy LLM pool ([#2122](https://github.com/block/buzz/pull/2122)) ([`61cc738ee8`](https://github.com/block/buzz/commit/61cc738ee8991e92563136de4b77e54cb9756420)) +- feat(media): add S3-truth per-community storage sweep ([#2044](https://github.com/block/buzz/pull/2044)) ([`bd37a4d584`](https://github.com/block/buzz/commit/bd37a4d584fefc1d13ad8abadf6e890e66183072)) +- feat(relay): log NIP-98 pubkey attribution on HTTP bridge requests ([#2206](https://github.com/block/buzz/pull/2206)) ([`7e34bee62c`](https://github.com/block/buzz/commit/7e34bee62cacaa9d8a96c14d5892a471b59a1983)) +- Revert "feat(relay): inventory unreachable Git objects" ([#2275](https://github.com/block/buzz/pull/2275)) ([`0fb820f9bf`](https://github.com/block/buzz/commit/0fb820f9bfbd7e19e48f9826e332920c2ee2c229)) +- feat(relay): inventory unreachable Git objects ([#2264](https://github.com/block/buzz/pull/2264)) ([`3afc9dae15`](https://github.com/block/buzz/commit/3afc9dae159262220c4149e9c8add50772869318)) +- relay: add author_type label to buzz_events_stored_total ([#2243](https://github.com/block/buzz/pull/2243)) ([`b9f54c43fe`](https://github.com/block/buzz/commit/b9f54c43fe2bcd0eb8fb3b76914e9aa0c31f6927)) +- fix(git): make project branch workflows reliable ([#2213](https://github.com/block/buzz/pull/2213)) ([`166f27be4b`](https://github.com/block/buzz/commit/166f27be4bc1abf2d465493bf2137353045399dc)) +- feat(cli): manage repository protection rules ([#2193](https://github.com/block/buzz/pull/2193)) ([`f94324598d`](https://github.com/block/buzz/commit/f94324598d84b2db9a05a3fa1f855970c4c5b575)) +- feat(cli): add agents archive/unarchive/archived subcommands ([#2173](https://github.com/block/buzz/pull/2173)) ([`7d7992067b`](https://github.com/block/buzz/commit/7d7992067b2914b582b7e6d31a6174603b480b4b)) +- fix(mobile): sanitize Android image uploads ([#2188](https://github.com/block/buzz/pull/2188)) ([`ee21da90bd`](https://github.com/block/buzz/commit/ee21da90bd6b1da6bfaaf22ba00749398aaa9640)) +- fix(cli): paginate channel directory queries ([#2181](https://github.com/block/buzz/pull/2181)) ([`03fe19d603`](https://github.com/block/buzz/commit/03fe19d6033094ae2ec4c89c26eb23174ef53daa)) +- fix(mobile): image upload fails due to unstripped metadata ([#2185](https://github.com/block/buzz/pull/2185)) ([`37f15b2001`](https://github.com/block/buzz/commit/37f15b20019169363b697aee41c99573b7bc3f24)) +- perf(relay): compact Git packs before manifest limits ([#2172](https://github.com/block/buzz/pull/2172)) ([`80e0ab16b0`](https://github.com/block/buzz/commit/80e0ab16b03c656ec8def18bedc27eaf29c02867)) +- perf(relay): cache Git pack hydration ([#2169](https://github.com/block/buzz/pull/2169)) ([`a4d82ec722`](https://github.com/block/buzz/commit/a4d82ec7226e685a933dbd829b6bb8bce0787b4e)) +- fix(relay): bound and observe Git read operations ([#2167](https://github.com/block/buzz/pull/2167)) ([`5f7c93d9c1`](https://github.com/block/buzz/commit/5f7c93d9c12ce7894288ae47f3fe223fcff2dce3)) +- relay: gate push enqueue on live leases; batch matcher pipeline (T1b/T1a-repair/T2b) ([#2145](https://github.com/block/buzz/pull/2145)) ([`e43b2d5aac`](https://github.com/block/buzz/commit/e43b2d5aac0d1f2b6b623b04f7af5a51f77da8c6)) +- relay: add audit logging disable switch ([#2134](https://github.com/block/buzz/pull/2134)) ([`bf5acabdde`](https://github.com/block/buzz/commit/bf5acabdde44aa133bdcbafcf9e1a4ff752c3302)) +- relay: skip TTL deadline bump for known-permanent channels (T1a write-amp) ([#2125](https://github.com/block/buzz/pull/2125)) ([`2e936d439c`](https://github.com/block/buzz/commit/2e936d439ce29182b48086f9f8a7a3ffe3b9b345)) +- fix(git): carry NIP-OA delegation in auth event ([#2120](https://github.com/block/buzz/pull/2120)) ([`c12257d57a`](https://github.com/block/buzz/commit/c12257d57a54d5c1e16435440b02beb5d1c057b8)) +- Route lag-tolerant reads to an optional Postgres read replica ([#2084](https://github.com/block/buzz/pull/2084)) ([`29c48883d3`](https://github.com/block/buzz/commit/29c48883d30e6feed75e33490571ca96082c6282)) +- fix: recover community access visibility ([#2074](https://github.com/block/buzz/pull/2074)) ([`ca384d082d`](https://github.com/block/buzz/commit/ca384d082d9804ec53a3fd12ccbf4a0846b21d92)) +- feat: proxy feedback-scoped admin attachments ([#2059](https://github.com/block/buzz/pull/2059)) ([`d7f918e3cb`](https://github.com/block/buzz/commit/d7f918e3cbcc4d30f222d0f4ae836de808f859a2)) +- feat: add read-only deployment moderation dashboard ([#1999](https://github.com/block/buzz/pull/1999)) ([`68e670e001`](https://github.com/block/buzz/commit/68e670e001d2bed2cf141095926feaf482c3bed8)) +- Bug-bash round 2: table scroll, Goose instructions, workflow mention wake ([#2034](https://github.com/block/buzz/pull/2034)) ([`64b8fea6dc`](https://github.com/block/buzz/commit/64b8fea6dce3be684aa0bac5dbd701e46dc7e432)) +- Strip media metadata on clients and reject it at the relay ([#2006](https://github.com/block/buzz/pull/2006)) ([`5cfd69cb0c`](https://github.com/block/buzz/commit/5cfd69cb0cf1dc63d718454defe3b8a8aaf5f15b)) +- [codex] Hold Git concurrency permits through streaming (BUZZ-SEC-018) ([#1916](https://github.com/block/buzz/pull/1916)) ([`7baea42abb`](https://github.com/block/buzz/commit/7baea42abbbb794e6e5ab0e9df11e2d1b0550d0b)) +- [codex] Enforce shared relay admission limits (BUZZ-SEC-019) ([#1917](https://github.com/block/buzz/pull/1917)) ([`73fc0ec6cf`](https://github.com/block/buzz/commit/73fc0ec6cf58a79bfc65e42faba457bf49c2d232)) +- [codex] Block banned actors from moderation commands (BUZZ-SEC-007) ([#1915](https://github.com/block/buzz/pull/1915)) ([`caa195ca58`](https://github.com/block/buzz/commit/caa195ca58ea49cf8ed9c3ede55d6a2e4ed37096)) +- [codex] Fix relay WebSocket admission limits ([#1682](https://github.com/block/buzz/pull/1682)) ([`d3ce971fc7`](https://github.com/block/buzz/commit/d3ce971fc75a34162d5498c27ac4a1c30236630a)) +- feat: add invite QR and mobile direct join ([#1957](https://github.com/block/buzz/pull/1957)) ([`648cbf3610`](https://github.com/block/buzz/commit/648cbf36109d97be6bd8530e77073d1c7e6008a0)) +- fix(join-policy): require legal consent on hosted invites ([#1987](https://github.com/block/buzz/pull/1987)) ([`2e1577f76f`](https://github.com/block/buzz/commit/2e1577f76f5105ddacda7be884518574ca8d6b96)) +- [codex] Prevent actor-tag UI impersonation ([#1931](https://github.com/block/buzz/pull/1931)) ([`c540ec9678`](https://github.com/block/buzz/commit/c540ec967869ef0f4eef90439bf70929fc74f7f6)) +- Scope relay runtime state by community ([#1658](https://github.com/block/buzz/pull/1658)) ([`d52dedb06f`](https://github.com/block/buzz/commit/d52dedb06fc2c7692c6d9225c7a08b41a509633a)) +- Apply optional relay join policy across join flows ([#1894](https://github.com/block/buzz/pull/1894)) ([`6c2d667575`](https://github.com/block/buzz/commit/6c2d667575cbc372ba42d26134448660fb1d2ee9)) +- feat(media): require auth for relay media reads ([#1926](https://github.com/block/buzz/pull/1926)) ([`f308762852`](https://github.com/block/buzz/commit/f3087628524951de91028c9d263bcd0d0a727fab)) +- feat(relay): add community unarchive endpoint ([#1908](https://github.com/block/buzz/pull/1908)) ([`6b9641db2b`](https://github.com/block/buzz/commit/6b9641db2b4709b71622b7ef0b799117a0605ca6)) +- feat(relay): gate Git web GUI separately ([#1901](https://github.com/block/buzz/pull/1901)) ([`34dc7dec75`](https://github.com/block/buzz/commit/34dc7dec75285ab6f2107ce5cad170b80b92206a)) +- mesh: upgrade runtime, enforce membership, add shared compute provider ([#1656](https://github.com/block/buzz/pull/1656)) ([`54638ff4bb`](https://github.com/block/buzz/commit/54638ff4bb5af2d3d3759b44118b43052f814bb1)) +- Route Git scratch through configured volume ([#1884](https://github.com/block/buzz/pull/1884)) ([`2318b3096c`](https://github.com/block/buzz/commit/2318b3096c585f8d31bd43e27dc5d6305c5fe20d)) +- feat(relay): gate usage metrics behind stable leader ([#1814](https://github.com/block/buzz/pull/1814)) ([`59e9821503`](https://github.com/block/buzz/commit/59e9821503a2fe23fc4630aa0f36bb252ae4566f)) +- Relay mesh: cross-pod tunnel + huddle transport (buzz-relay-mesh) ([#1670](https://github.com/block/buzz/pull/1670)) ([`ccb021d713`](https://github.com/block/buzz/commit/ccb021d71339009aabedc383c8f3d8e5c23e1e42)) +- feat(push): deliver accepted relay events as wakes ([#1866](https://github.com/block/buzz/pull/1866)) ([`bffbc5f22c`](https://github.com/block/buzz/commit/bffbc5f22cc80e9a07dedc622798347d598a215c)) +- fix(db): resolve duplicate migration version ([#1863](https://github.com/block/buzz/pull/1863)) ([`08ad38a07f`](https://github.com/block/buzz/commit/08ad38a07f0c49bb3f20b775b8f534f1cfa529c3)) +- Add private product feedback sidecar ([#1857](https://github.com/block/buzz/pull/1857)) ([`af190c93e1`](https://github.com/block/buzz/commit/af190c93e1048af64c3fbfb3831c689cb703997c)) +- feat(relay): add durable community archival ([#1834](https://github.com/block/buzz/pull/1834)) ([`2b15a72675`](https://github.com/block/buzz/commit/2b15a726750dbd7437711050cd3241b679dff317)) +- feat(push): add public APNs gateway ([#1770](https://github.com/block/buzz/pull/1770)) ([`1c006822e4`](https://github.com/block/buzz/commit/1c006822e4484d68e33fce14f9139c2f70ce9d66)) +- feat(relay): add atomic community ownership transfer ([#1845](https://github.com/block/buzz/pull/1845)) ([`52e42ccb9f`](https://github.com/block/buzz/commit/52e42ccb9fc85445814614c72d40f346e986152b)) +- Bound NIP-RS retention and search indexing ([#1771](https://github.com/block/buzz/pull/1771)) ([`1b4703021d`](https://github.com/block/buzz/commit/1b4703021dbfd37dc31845223dba9ba182e4647f)) +- Add optional standalone pairing relay to Helm chart ([#1799](https://github.com/block/buzz/pull/1799)) ([`9b47c8548f`](https://github.com/block/buzz/commit/9b47c8548fd061fbb806ea8b9ddee831c19cf80e)) +- fix(relay): publish membership snapshot on provisioning ([#1761](https://github.com/block/buzz/pull/1761)) ([`0950d392b7`](https://github.com/block/buzz/commit/0950d392b7a862694c95cbea1cec45985ee42996)) +- feat(relay): per-community usage metrics ([#1723](https://github.com/block/buzz/pull/1723)) ([`620822899a`](https://github.com/block/buzz/commit/620822899a6373fa3a17a87815cd7cade25ed332)) +- refactor(desktop): remove vestigial MCP toolsets config ([#1776](https://github.com/block/buzz/pull/1776)) ([`dfec75b3c0`](https://github.com/block/buzz/commit/dfec75b3c0b8080529e4d9089d4ed80e3902aaed)) + + ## relay-v0.2.0 - feat: relay invite links (mint + claim + landing page + deep link) ([#1668](https://github.com/block/buzz/pull/1668)) ([`2e529aab7`](https://github.com/block/buzz/commit/2e529aab759a18c1bb81e447f3696fe99db53a27)) diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index cbad2a3b29..65fe32b6b3 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -4,7 +4,7 @@ default-run = "buzz-relay" # Independent version: buzz-relay ships as a pinnable artifact # (ghcr.io/block/buzz), released on its own cadence via `just release-relay`. # It does NOT inherit the workspace version. -version = "0.2.0" +version = "0.2.1" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 29bfd7dbab..d24c8fab79 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -747,7 +747,7 @@ function PromptContextDialog({ return ( -
+
Prompt context {setupText ? ( diff --git a/desktop/src/features/agents/ui/PromptSectionAccordion.tsx b/desktop/src/features/agents/ui/PromptSectionAccordion.tsx index 0b5e58cc6c..ad5346db15 100644 --- a/desktop/src/features/agents/ui/PromptSectionAccordion.tsx +++ b/desktop/src/features/agents/ui/PromptSectionAccordion.tsx @@ -47,7 +47,7 @@ export function PromptSectionAccordion({
{section.title} @@ -56,8 +56,8 @@ export function PromptSectionAccordion({ className={cn( "mt-1 text-xs leading-5 text-foreground/70", open - ? "whitespace-pre-wrap wrap-break-word" - : "line-clamp-2 wrap-break-word", + ? "whitespace-pre-wrap wrap-anywhere" + : "line-clamp-2 wrap-anywhere", )} > {body.length > 0 ? ( diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 7ea63f2339..13790d779f 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -43,12 +43,8 @@ import { ChannelComposerActivityAccessory } from "@/features/channels/ui/Channel import { containsWelcomePersonaMention, WelcomeComposerGuidanceLayer, - WELCOME_COMPOSER_BANNER_DISMISS_DURATION_SECONDS, - WELCOME_COMPOSER_BANNER_HIDE_BUFFER_MS, - WELCOME_COMPOSER_BANNER_SUCCESS_SETTLE_MS, - WELCOME_PERSONA_ROTATION_MS, - type WelcomeComposerBannerState, } from "@/features/channels/ui/WelcomeComposerBanner"; +import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; import { useChannelIntro } from "@/features/channels/ui/useChannelIntro"; @@ -168,11 +164,6 @@ export const ChannelPane = React.memo(function ChannelPane({ const timelineScrollRef = React.useRef(null); const messageTimelineRef = React.useRef(null); const composerWrapperRef = React.useRef(null); - const completedWelcomeBannerChannelIdsRef = React.useRef(new Set()); - const welcomeComposerDismissTimerRef = React.useRef(null); - const welcomeComposerHideTimerRef = React.useRef(null); - const [welcomeComposerBannerState, setWelcomeComposerBannerState] = - React.useState("prompt"); const { goChannel } = useAppNavigation(); const prepareDmSendChannel = usePrepareDmSendChannel( activeChannel, @@ -221,36 +212,11 @@ export const ChannelPane = React.memo(function ChannelPane({ "css-variable", () => messageTimelineRef.current?.settleAtBottom() ?? false, ); - const clearWelcomeComposerDismissTimer = React.useCallback(() => { - if (welcomeComposerDismissTimerRef.current !== null) { - window.clearTimeout(welcomeComposerDismissTimerRef.current); - welcomeComposerDismissTimerRef.current = null; - } - if (welcomeComposerHideTimerRef.current !== null) { - window.clearTimeout(welcomeComposerHideTimerRef.current); - welcomeComposerHideTimerRef.current = null; - } - }, []); - React.useEffect( - () => () => clearWelcomeComposerDismissTimer(), - [clearWelcomeComposerDismissTimer], - ); - React.useEffect(() => { - clearWelcomeComposerDismissTimer(); - if ( - activeChannelId && - isActiveWelcomeChannel && - completedWelcomeBannerChannelIdsRef.current.has(activeChannelId) - ) { - setWelcomeComposerBannerState("hidden"); - return; - } - setWelcomeComposerBannerState("prompt"); - }, [ - activeChannelId, - clearWelcomeComposerDismissTimer, - isActiveWelcomeChannel, - ]); + const { + bannerState: welcomeComposerBannerState, + completeBanner: completeWelcomeComposerBanner, + dismissBanner: handleDismissWelcomeBanner, + } = useWelcomeComposerBanner(activeChannelId, isActiveWelcomeChannel); const isEditInThread = editTarget != null && threadHeadMessage != null && @@ -330,31 +296,6 @@ export const ChannelPane = React.memo(function ChannelPane({ return pubkeys; }, [activityAgents, agentPubkeys, agentSessionAgents]); - const completeWelcomeComposerBanner = React.useCallback(() => { - if (!activeChannelId || !isActiveWelcomeChannel) { - return; - } - - clearWelcomeComposerDismissTimer(); - completedWelcomeBannerChannelIdsRef.current.add(activeChannelId); - setWelcomeComposerBannerState("complete"); - welcomeComposerDismissTimerRef.current = window.setTimeout(() => { - setWelcomeComposerBannerState("dismissing"); - welcomeComposerDismissTimerRef.current = null; - welcomeComposerHideTimerRef.current = window.setTimeout( - () => { - setWelcomeComposerBannerState("hidden"); - welcomeComposerHideTimerRef.current = null; - }, - WELCOME_COMPOSER_BANNER_DISMISS_DURATION_SECONDS * 1000 + - WELCOME_COMPOSER_BANNER_HIDE_BUFFER_MS, - ); - }, WELCOME_PERSONA_ROTATION_MS + WELCOME_COMPOSER_BANNER_SUCCESS_SETTLE_MS); - }, [ - activeChannelId, - clearWelcomeComposerDismissTimer, - isActiveWelcomeChannel, - ]); const handleSendMessage = React.useCallback( async ( content: string, @@ -739,6 +680,7 @@ export const ChannelPane = React.memo(function ChannelPane({ > {isActiveWelcomeChannel && !timeoutState.active ? ( diff --git a/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx b/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx index f75b8dbeb9..f050dd4b9f 100644 --- a/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx +++ b/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { Bot, Check } from "lucide-react"; +import { Bot, Check, X } from "lucide-react"; import { ComposerDockGlassBackdrop } from "@/features/messages/ui/ComposerDockBackdrop"; import { cn } from "@/shared/lib/cn"; @@ -291,9 +291,15 @@ type WelcomeComposerBannerProps = { * banner during this window. */ settingUp?: boolean; + /** + * Called when the user dismisses the banner manually via the close button. + * Only rendered while `state === "prompt"`. + */ + onDismiss?: () => void; }; export function WelcomeComposerBanner({ + onDismiss, settingUp = false, state, }: WelcomeComposerBannerProps) { @@ -307,7 +313,7 @@ export function WelcomeComposerBanner({ animate={{ height: state === "dismissing" ? 0 : "auto", }} - className="overflow-visible" + className="overflow-hidden" initial={false} transition={{ duration: @@ -326,7 +332,7 @@ export function WelcomeComposerBanner({ : 0, }} className={cn( - "relative z-[1] mx-5 -mb-3 flex items-center gap-2 rounded-t-2xl border border-b-0 px-4 pb-5 pt-2.5 text-sm leading-5 transition-colors", + "relative z-[1] mx-5 mb-0 flex items-center gap-2 rounded-t-2xl border border-b-0 px-4 pb-5 pt-2.5 text-sm leading-5 transition-colors", state !== "prompt" ? "border-emerald-500/30 bg-emerald-500/15 text-foreground" : "border-border/60 bg-muted/55 text-muted-foreground", @@ -376,7 +382,7 @@ export function WelcomeComposerBanner({ {state !== "prompt" ? ( )} + {state === "prompt" && onDismiss && !settingUp ? ( + + ) : null} @@ -422,22 +439,22 @@ type WelcomeComposerGuidanceLayerProps = WelcomeComposerBannerProps & { export function WelcomeComposerGuidanceLayer({ children, + onDismiss, settingUp, state, }: WelcomeComposerGuidanceLayerProps) { return ( -
-
- - {children} - -
+
+ + {children} +
); } diff --git a/desktop/src/features/channels/ui/useWelcomeComposerBanner.ts b/desktop/src/features/channels/ui/useWelcomeComposerBanner.ts new file mode 100644 index 0000000000..eb09eddd0e --- /dev/null +++ b/desktop/src/features/channels/ui/useWelcomeComposerBanner.ts @@ -0,0 +1,99 @@ +import * as React from "react"; + +import { + WELCOME_COMPOSER_BANNER_DISMISS_DURATION_SECONDS, + WELCOME_COMPOSER_BANNER_HIDE_BUFFER_MS, + WELCOME_COMPOSER_BANNER_SUCCESS_SETTLE_MS, + WELCOME_PERSONA_ROTATION_MS, + type WelcomeComposerBannerState, +} from "@/features/channels/ui/WelcomeComposerBanner"; + +/** + * Manages the Welcome-channel composer hint banner's state machine. + * + * Tracks which channels have been completed within the session so the banner + * stays hidden on re-entry. Exposes three transitions: + * - `completeBanner`: agent-mention path — plays the "Nice work." success + * animation before auto-dismissing. + * - `dismissBanner`: manual X-button path — immediately begins the slide-down + * dismiss animation. + */ +export function useWelcomeComposerBanner( + activeChannelId: string | null, + isActiveWelcomeChannel: boolean, +): { + bannerState: WelcomeComposerBannerState; + completeBanner: () => void; + dismissBanner: () => void; +} { + const completedChannelIdsRef = React.useRef(new Set()); + const dismissTimerRef = React.useRef(null); + const hideTimerRef = React.useRef(null); + const [bannerState, setBannerState] = + React.useState("prompt"); + + const clearTimers = React.useCallback(() => { + if (dismissTimerRef.current !== null) { + window.clearTimeout(dismissTimerRef.current); + dismissTimerRef.current = null; + } + if (hideTimerRef.current !== null) { + window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + }, []); + + React.useEffect(() => () => clearTimers(), [clearTimers]); + + React.useEffect(() => { + clearTimers(); + if ( + activeChannelId && + isActiveWelcomeChannel && + completedChannelIdsRef.current.has(activeChannelId) + ) { + setBannerState("hidden"); + return; + } + setBannerState("prompt"); + }, [activeChannelId, clearTimers, isActiveWelcomeChannel]); + + const scheduleHide = React.useCallback(() => { + hideTimerRef.current = window.setTimeout( + () => { + setBannerState("hidden"); + hideTimerRef.current = null; + }, + WELCOME_COMPOSER_BANNER_DISMISS_DURATION_SECONDS * 1000 + + WELCOME_COMPOSER_BANNER_HIDE_BUFFER_MS, + ); + }, []); + + const completeBanner = React.useCallback(() => { + if (!activeChannelId || !isActiveWelcomeChannel) { + return; + } + + clearTimers(); + completedChannelIdsRef.current.add(activeChannelId); + setBannerState("complete"); + dismissTimerRef.current = window.setTimeout(() => { + setBannerState("dismissing"); + dismissTimerRef.current = null; + scheduleHide(); + }, WELCOME_PERSONA_ROTATION_MS + WELCOME_COMPOSER_BANNER_SUCCESS_SETTLE_MS); + }, [activeChannelId, clearTimers, isActiveWelcomeChannel, scheduleHide]); + + const dismissBanner = React.useCallback(() => { + if (!activeChannelId || !isActiveWelcomeChannel) { + return; + } + + clearTimers(); + completedChannelIdsRef.current.add(activeChannelId); + setBannerState("dismissing"); + scheduleHide(); + }, [activeChannelId, clearTimers, isActiveWelcomeChannel, scheduleHide]); + + return { bannerState, completeBanner, dismissBanner }; +} diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index e5217da3d3..5e0e6a4fa2 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -207,6 +207,7 @@ async function expectWelcomeComposerBannerLayout(page: Page) { .locator("div") .boundingBox(); const guidanceLayer = page.getByTestId("welcome-composer-guidance-layer"); + const guidanceLayerBox = await guidanceLayer.boundingBox(); const guidanceBackdrop = page.getByTestId( "welcome-composer-guidance-backdrop", ); @@ -217,6 +218,7 @@ async function expectWelcomeComposerBannerLayout(page: Page) { !personaMentionBox || !composerBox || !dockBackdropBox || + !guidanceLayerBox || !guidanceBackdropBox ) { throw new Error("Could not measure welcome composer banner layout"); @@ -226,23 +228,20 @@ async function expectWelcomeComposerBannerLayout(page: Page) { await composer.getByTestId("welcome-composer-guide-banner").count(), ).toBe(0); expect(bannerBox.y).toBeLessThan(composerBox.y); - expect(bannerBox.y + bannerBox.height).toBeGreaterThan(composerBox.y); - expect(Math.abs(dockBackdropBox.y - composerBox.y)).toBeLessThanOrEqual(1); + // Banner is in normal flow above the composer, no overlap. + expect(bannerBox.y + bannerBox.height).toBeLessThanOrEqual(composerBox.y); + // The dock backdrop is absolute inset-y-0 inside composer-dock, which now + // contains the guidance layer + composer in flow, so its top aligns with the + // guidance layer top (not the composer top). + expect(Math.abs(dockBackdropBox.y - guidanceLayerBox.y)).toBeLessThanOrEqual( + 1, + ); expect(guidanceBackdropBox.y).toBeLessThanOrEqual(bannerBox.y); - expect( - Math.abs( - guidanceBackdropBox.y + guidanceBackdropBox.height - composerBox.y, - ), - ).toBeLessThanOrEqual(1); - const [guidanceZIndex, backdropZIndex] = await Promise.all([ - guidanceLayer.evaluate((element) => - Number(window.getComputedStyle(element).zIndex), - ), - page - .getByTestId("composer-dock-backdrop") - .evaluate((element) => Number(window.getComputedStyle(element).zIndex)), - ]); - expect(guidanceZIndex).toBeLessThan(backdropZIndex); + // The guidance backdrop extends bottom-3 (12px) short of the banner's bottom, + // visually connecting up to the composer. + expect(guidanceBackdropBox.y + guidanceBackdropBox.height).toBeLessThan( + composerBox.y, + ); expect( await page .getByTestId("channel-composer-overlay") @@ -3170,6 +3169,59 @@ test("finishing onboarding creates starter channels and focuses welcome-everyone await expectWelcomeComposerBannerCompletesAfterPersonaMention(page); }); +test("welcome-everywhere banner: X dismiss removes the guidance surface", async ({ + page, +}) => { + await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); + await installMockBridge(page, undefined, { skipOnboardingSeed: true }); + await page.goto("/"); + + await page.getByTestId("onboarding-display-name").fill("Morty QA"); + await completeProfileOnboarding(page); + + const banner = page.getByTestId("welcome-composer-guide-banner"); + const guidanceLayer = page.getByTestId("welcome-composer-guidance-layer"); + const dismissButton = page.getByTestId("welcome-composer-dismiss-button"); + + // Banner and guidance layer are visible in the prompt state. + await expect(banner).toBeVisible(); + await expect(guidanceLayer).toBeVisible(); + await expect(dismissButton).toBeVisible(); + + await dismissButton.click(); + + // After dismiss the entire guidance surface must be gone. + await expect(banner).toHaveCount(0, { timeout: 2_000 }); + await expect(guidanceLayer).toHaveCount(0); +}); + +test("welcome-everywhere banner: dismiss persists after channel re-entry", async ({ + page, +}) => { + await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); + await installMockBridge(page, undefined, { skipOnboardingSeed: true }); + await page.goto("/"); + + await page.getByTestId("onboarding-display-name").fill("Morty QA"); + await completeProfileOnboarding(page); + + const banner = page.getByTestId("welcome-composer-guide-banner"); + + await expect(banner).toBeVisible(); + await page.getByTestId("welcome-composer-dismiss-button").click(); + await expect(banner).toHaveCount(0, { timeout: 2_000 }); + + // Leave the Welcome channel. + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toContainText("general"); + await expect(banner).toHaveCount(0); + + // Return — banner must stay hidden. + await page.getByTestId("channel-welcome-everyone").click(); + await expect(page.getByTestId("chat-title")).toContainText("Welcome"); + await expect(banner).toHaveCount(0); +}); + test("initial profile read failures still hold incomplete users in onboarding", async ({ page, }) => {