Merge remote-tracking branch 'origin/main' into benchmark/harness-accounting-and-solo

* origin/main:
  fix(agent): retry LLM completion on malformed 2xx JSON body (#5351)
  fix(desktop): welcome banner overlap and missing dismiss control (#5330)
  fix(desktop): prevent horizontal clipping in Prompt Context modal (#5324)
  chore(release): release Buzz Relay version 0.2.1 (#2856)

Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
This commit is contained in:
Eva
2026-08-08 21:57:41 -04:00
10 changed files with 607 additions and 108 deletions

2
Cargo.lock generated
View File

@@ -1180,7 +1180,7 @@ dependencies = [
[[package]]
name = "buzz-relay"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"anyhow",
"async-compression",

View File

@@ -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.
///

View File

@@ -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))

View File

@@ -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

View File

@@ -747,7 +747,7 @@ function PromptContextDialog({
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-xl overflow-hidden p-0">
<div className="flex max-h-[85vh] flex-col">
<div className="flex min-w-0 max-h-[85vh] flex-col">
<DialogHeader className="px-6 pb-3 pt-5 pr-14">
<DialogTitle>Prompt context</DialogTitle>
{setupText ? (

View File

@@ -47,7 +47,7 @@ export function PromptSectionAccordion({
<div
className={cn(
"text-sm font-semibold text-foreground",
!open && "line-clamp-2 wrap-break-word",
!open && "line-clamp-2 wrap-anywhere",
)}
>
{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 ? (

View File

@@ -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<HTMLDivElement>(null);
const messageTimelineRef = React.useRef<MessageTimelineHandle>(null);
const composerWrapperRef = React.useRef<HTMLDivElement>(null);
const completedWelcomeBannerChannelIdsRef = React.useRef(new Set<string>());
const welcomeComposerDismissTimerRef = React.useRef<number | null>(null);
const welcomeComposerHideTimerRef = React.useRef<number | null>(null);
const [welcomeComposerBannerState, setWelcomeComposerBannerState] =
React.useState<WelcomeComposerBannerState>("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 ? (
<WelcomeComposerGuidanceLayer
onDismiss={handleDismissWelcomeBanner}
settingUp={welcomeKickoffSettingUp}
state={welcomeComposerBannerState}
>

View File

@@ -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" ? (
<motion.span
animate="animate"
className="min-w-0"
className="min-w-0 flex-1"
data-animation-target="success-copy"
initial="initial"
key="complete-copy"
@@ -387,7 +393,7 @@ export function WelcomeComposerBanner({
) : settingUp ? (
<motion.span
animate="animate"
className="min-w-0"
className="min-w-0 flex-1"
data-testid="welcome-composer-setting-up-copy"
exit="exit"
initial="initial"
@@ -399,7 +405,7 @@ export function WelcomeComposerBanner({
) : (
<motion.span
animate="animate"
className="min-w-0"
className="min-w-0 flex-1"
exit="exit"
initial="initial"
key="prompt-copy"
@@ -410,6 +416,17 @@ export function WelcomeComposerBanner({
</motion.span>
)}
</AnimatePresence>
{state === "prompt" && onDismiss && !settingUp ? (
<button
aria-label="Dismiss hint"
className="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid="welcome-composer-dismiss-button"
onClick={onDismiss}
type="button"
>
<X aria-hidden className="h-3 w-3" />
</button>
) : null}
</motion.div>
</motion.div>
</AnimatePresence>
@@ -422,22 +439,22 @@ type WelcomeComposerGuidanceLayerProps = WelcomeComposerBannerProps & {
export function WelcomeComposerGuidanceLayer({
children,
onDismiss,
settingUp,
state,
}: WelcomeComposerGuidanceLayerProps) {
return (
<div
className="absolute inset-x-0 bottom-full z-[-1]"
data-testid="welcome-composer-guidance-layer"
>
<div className="relative">
<ComposerDockGlassBackdrop
className="absolute inset-x-5 top-0 bottom-3 z-0 rounded-t-2xl"
testId="welcome-composer-guidance-backdrop"
/>
{children}
<WelcomeComposerBanner settingUp={settingUp} state={state} />
</div>
<div className="relative" data-testid="welcome-composer-guidance-layer">
<ComposerDockGlassBackdrop
className="absolute inset-x-5 top-0 bottom-3 z-0 rounded-t-2xl"
testId="welcome-composer-guidance-backdrop"
/>
{children}
<WelcomeComposerBanner
onDismiss={onDismiss}
settingUp={settingUp}
state={state}
/>
</div>
);
}

View File

@@ -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<string>());
const dismissTimerRef = React.useRef<number | null>(null);
const hideTimerRef = React.useRef<number | null>(null);
const [bannerState, setBannerState] =
React.useState<WelcomeComposerBannerState>("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 };
}

View File

@@ -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,
}) => {