mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(desktop): emit singular mention feed category so alerts route correctly (#6665)
## Problem Mentions — and thread replies that @-mention you — played the **Needs action** sound instead of the **@Mentions** sound. Reported by @morgmart: "I set Needs action to a different sound and it's the only one I ever hear." ## Root cause Two vocabularies got conflated in #475: - **Section / filter vocabulary** (plural): `mentions`, `needs_action`, `activity`, `agent_activity` — the shape of `FeedSections`, the `--types` filter, and the agent-facing CLI docs. - **Per-item category vocabulary** (singular for mention): `mention`, `needs_action`, `activity`, `agent_activity` — the `FeedItemCategory` contract in `desktop/src/shared/api/types.ts`, unchanged since #12. The Tauri feed builder reused the filter string `"mentions"` as each mention item's `category`. Only one word differs between the vocabularies, so only mentions broke. Every frontend consumer compares against the singular, so real mentions never matched and fell through to the resolver's `needs_action` fallback. The E2E mock bridge emits the singular form, so tests never saw the drift. ### Symptoms this fixes (all from the one mislabel) - Mentions and mentioning thread replies played the Needs-action sound - Mention notifications used the Needs-action title format - Mentions in muted channels were suppressed (the mute-bypass never fired) - Inbox / Home feed labelled mentions "Channel update" - Channel activity popover's mentions list was always empty ## Fix **Fix the owner, not the symptoms.** `FeedItemInfo.category` becomes a `FeedItemCategory` enum whose serde form is exactly the TS union, so a misspelled category can't compile at the producer. A serialization test pins each variant to its wire string. **Frontend:** `slotForFeedKind` maps every known category explicitly. The `needs_action` fallback for unknown categories is **kept on purpose** — a contract drift should cost the user the wrong sound, not a missed alert — but it now `console.warn`s so the drift is visible to developers instead of masquerading as intended behavior. `e2eBridge.ts` and `tauri.ts` now derive the category type from `types.ts` instead of retyping it. Not touched: the plural `--types` filter and `FeedSections` keys. Those are the section vocabulary and are correct as-is. ## Verification - `just ci` green (file-size ratchet, Rust/Tauri/desktop/mobile tests, desktop + web builds) - New tests: 2 Rust (`feed_item_category_serializes_to_frontend_contract`, `feed_item_from_event_carries_singular_mention_category`), 3 TS in `sound.test.mjs` incl. one that feeds the old `"mentions"` string and asserts fallback + warning - **Runtime, dev build against the production relay:** controlled test from an agent identity into a test channel — - mention in channel → @Mentions sound, inbox shows "Mentioned in" ✅ (was Needs-action) - thread reply with mention → @Mentions sound, once ✅ (was Needs-action) - plain thread reply in the channel being viewed → silent, as designed ✅ ## Reviewers - @tlongwell-block — #475 introduced the plural category; please confirm it wasn't intentional - @wesbillman — owner of the original `FeedItemCategory` contract (#12) and most of the feed builder - @taylorkmho — owner of the sound-slot model and resolver (#968); the fallback-with-warning shape is the part to weigh in on - cc @klopez4212 --------- Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ use crate::{
|
||||
events,
|
||||
managed_agents::{find_managed_agent_mut, load_managed_agents, ManagedAgentRecord},
|
||||
models::{
|
||||
FeedItemInfo, FeedMeta, FeedResponse, FeedSections, SearchResponse,
|
||||
FeedItemCategory, FeedItemInfo, FeedMeta, FeedResponse, FeedSections, SearchResponse,
|
||||
SendChannelMessageResponse, ThreadRepliesResponse,
|
||||
},
|
||||
nostr_convert,
|
||||
@@ -138,14 +138,14 @@ pub async fn get_feed(
|
||||
let mentions: Vec<FeedItemInfo> = mention_events
|
||||
.iter()
|
||||
.map(|ev| {
|
||||
let mut item = feed_item_from_event(ev, "mentions");
|
||||
let mut item = feed_item_from_event(ev, FeedItemCategory::Mention);
|
||||
apply_link_preview_suppression(&mut item.tags, &item.id, &suppressed_mentions);
|
||||
item
|
||||
})
|
||||
.collect();
|
||||
let needs_action: Vec<FeedItemInfo> = approval_events
|
||||
.iter()
|
||||
.map(|ev| feed_item_from_event(ev, "needs_action"))
|
||||
.map(|ev| feed_item_from_event(ev, FeedItemCategory::NeedsAction))
|
||||
.collect();
|
||||
|
||||
let total = (mentions.len() + needs_action.len()) as u64;
|
||||
@@ -966,7 +966,7 @@ fn tags_to_vec(ev: &nostr::Event) -> Vec<Vec<String>> {
|
||||
ev.tags.iter().map(|t| t.as_slice().to_vec()).collect()
|
||||
}
|
||||
|
||||
fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo {
|
||||
fn feed_item_from_event(ev: &nostr::Event, category: FeedItemCategory) -> FeedItemInfo {
|
||||
let channel_id = channel_id_from_tags(ev);
|
||||
FeedItemInfo {
|
||||
id: ev.id.to_hex(),
|
||||
@@ -978,7 +978,7 @@ fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo {
|
||||
channel_name: String::new(),
|
||||
channel_type: None,
|
||||
tags: tags_to_vec(ev),
|
||||
category: category.to_string(),
|
||||
category,
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -236,3 +236,43 @@ fn provided_thread_ref_validates_and_preserves_root_and_parent() {
|
||||
assert_eq!(thread_ref.parent_event_id.to_hex(), parent);
|
||||
assert!(thread_ref::provided_thread_ref("not-hex", &parent).is_err());
|
||||
}
|
||||
|
||||
/// `FeedItem.category` is a wire contract with the desktop frontend
|
||||
/// (`desktop/src/shared/api/types.ts`). The frontend routes notification
|
||||
/// sounds, titles, mute-bypass, and inbox labels off these exact strings, so
|
||||
/// the serialized form must stay singular `mention` — not the plural section
|
||||
/// name `mentions` used by `FeedSections` and the `--types` filter.
|
||||
#[test]
|
||||
fn feed_item_category_serializes_to_frontend_contract() {
|
||||
let cases = [
|
||||
(FeedItemCategory::Mention, "mention"),
|
||||
(FeedItemCategory::NeedsAction, "needs_action"),
|
||||
(FeedItemCategory::Activity, "activity"),
|
||||
(FeedItemCategory::AgentActivity, "agent_activity"),
|
||||
];
|
||||
for (category, expected) in cases {
|
||||
let value = serde_json::to_value(category).expect("category should serialize");
|
||||
assert_eq!(value, serde_json::Value::String(expected.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_item_from_event_carries_singular_mention_category() {
|
||||
let pubkey = Keys::generate().public_key().to_hex();
|
||||
let event = build_managed_agent_channel_message(
|
||||
uuid::Uuid::new_v4(),
|
||||
"hey @you",
|
||||
None,
|
||||
std::slice::from_ref(&pubkey),
|
||||
&[],
|
||||
)
|
||||
.expect("message should build")
|
||||
.sign_with_keys(&Keys::generate())
|
||||
.expect("message should sign");
|
||||
|
||||
let item = feed_item_from_event(&event, FeedItemCategory::Mention);
|
||||
let json = serde_json::to_value(&item).expect("feed item should serialize");
|
||||
|
||||
assert_eq!(json["category"], "mention");
|
||||
assert_eq!(json["id"], event.id.to_hex());
|
||||
}
|
||||
|
||||
@@ -178,6 +178,22 @@ pub struct ChannelMembersResponse {
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// Per-item classification of a home feed entry.
|
||||
///
|
||||
/// This is the wire contract for `FeedItem.category` in the desktop frontend
|
||||
/// (`desktop/src/shared/api/types.ts`). It is distinct from the plural
|
||||
/// *section* vocabulary (`mentions`, `needs_action`, …) used by
|
||||
/// [`FeedSections`] and the `--types` filter: a mention item lives in the
|
||||
/// `mentions` section but carries the singular `mention` category.
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FeedItemCategory {
|
||||
Mention,
|
||||
NeedsAction,
|
||||
Activity,
|
||||
AgentActivity,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct FeedItemInfo {
|
||||
pub id: String,
|
||||
@@ -190,7 +206,7 @@ pub struct FeedItemInfo {
|
||||
#[serde(default)]
|
||||
pub channel_type: Option<String>,
|
||||
pub tags: Vec<Vec<String>>,
|
||||
pub category: String,
|
||||
pub category: FeedItemCategory,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
|
||||
@@ -1,7 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { shouldPlayNotificationSound } from "./sound.ts";
|
||||
import {
|
||||
KIND_APPROVAL_REQUEST,
|
||||
KIND_STREAM_MESSAGE_V2,
|
||||
KIND_JOB_ACCEPTED,
|
||||
} from "../../../shared/constants/kinds.ts";
|
||||
import { shouldPlayNotificationSound, slotForFeedKind } from "./sound.ts";
|
||||
|
||||
test("routes each feed category to its own sound slot", () => {
|
||||
assert.equal(slotForFeedKind(KIND_STREAM_MESSAGE_V2, "mention"), "mention");
|
||||
assert.equal(
|
||||
slotForFeedKind(KIND_APPROVAL_REQUEST, "needs_action"),
|
||||
"needs_action",
|
||||
);
|
||||
assert.equal(
|
||||
slotForFeedKind(KIND_STREAM_MESSAGE_V2, "activity"),
|
||||
"needs_action",
|
||||
);
|
||||
assert.equal(
|
||||
slotForFeedKind(KIND_STREAM_MESSAGE_V2, "agent_activity"),
|
||||
"needs_action",
|
||||
);
|
||||
});
|
||||
|
||||
test("agent job kinds pick their slot for non-mention categories", () => {
|
||||
assert.equal(
|
||||
slotForFeedKind(KIND_JOB_ACCEPTED, "agent_activity"),
|
||||
"job_accepted",
|
||||
);
|
||||
});
|
||||
|
||||
test("a mention outranks the agent job kind that carried it", () => {
|
||||
assert.equal(slotForFeedKind(KIND_JOB_ACCEPTED, "mention"), "mention");
|
||||
});
|
||||
|
||||
test("unknown category falls back to needs_action and warns", () => {
|
||||
const warnings = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args) => warnings.push(args.join(" "));
|
||||
try {
|
||||
// The backend once emitted the plural section name here; the fallback
|
||||
// keeps the user alerted while the warning keeps the drift visible.
|
||||
assert.equal(
|
||||
slotForFeedKind(KIND_STREAM_MESSAGE_V2, "mentions"),
|
||||
"needs_action",
|
||||
);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /unknown feed item category "mentions"/);
|
||||
});
|
||||
|
||||
test("silences notifications from Huddle backing channels", () => {
|
||||
const silentChannelIds = new Set(["active-huddle"]);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
KIND_APPROVAL_REQUEST,
|
||||
KIND_JOB_ACCEPTED,
|
||||
KIND_JOB_ERROR,
|
||||
KIND_JOB_PROGRESS,
|
||||
@@ -115,6 +114,17 @@ export function resolveSlotSound(
|
||||
return prefs.sounds[slot];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the sound slot for a home-feed item.
|
||||
*
|
||||
* `category` is the backend's per-item classification (`FeedItemCategory` in
|
||||
* `desktop/src-tauri/src/models.rs`). A mention always wins — being addressed
|
||||
* directly outranks whatever kind of event carried it, including agent job
|
||||
* events. Every other known category maps explicitly; anything else falls
|
||||
* back to `needs_action` so a contract drift costs the user the wrong sound
|
||||
* rather than a missed alert — and warns so the drift is visible to
|
||||
* developers instead of silently masquerading as intended.
|
||||
*/
|
||||
export function slotForFeedKind(
|
||||
kind: number,
|
||||
category: FeedItemCategory,
|
||||
@@ -124,8 +134,20 @@ export function slotForFeedKind(
|
||||
if (kind === KIND_JOB_PROGRESS) return "job_progress";
|
||||
if (kind === KIND_JOB_RESULT) return "job_result";
|
||||
if (kind === KIND_JOB_ERROR) return "job_error";
|
||||
if (kind === KIND_APPROVAL_REQUEST) return "needs_action";
|
||||
return "needs_action";
|
||||
|
||||
switch (category) {
|
||||
case "needs_action":
|
||||
case "activity":
|
||||
case "agent_activity":
|
||||
return "needs_action";
|
||||
default: {
|
||||
const unexpected: never = category;
|
||||
console.warn(
|
||||
`[notifications] unknown feed item category ${JSON.stringify(unexpected)} for kind ${kind}; falling back to needs_action`,
|
||||
);
|
||||
return "needs_action";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldPlayNotificationSound(
|
||||
|
||||
@@ -64,7 +64,7 @@ type RawFeedItem = {
|
||||
channel_name: string;
|
||||
channel_type: string | null;
|
||||
tags: string[][];
|
||||
category: "mention" | "needs_action" | "activity" | "agent_activity";
|
||||
category: HomeFeedResponse["feed"]["mentions"][number]["category"];
|
||||
};
|
||||
|
||||
type RawHomeFeedResponse = {
|
||||
|
||||
@@ -19,7 +19,11 @@ import { relayClient } from "@/shared/api/relayClient";
|
||||
import { activateRateLimit } from "@/shared/api/relayRateLimitGate";
|
||||
import { resolveAgentParallelism } from "@/features/agents/lib/agentParallelism";
|
||||
import type { ConnectionState } from "@/shared/api/relayClientShared";
|
||||
import type { ChannelTemplate, RelayEvent } from "@/shared/api/types";
|
||||
import type {
|
||||
ChannelTemplate,
|
||||
FeedItemCategory,
|
||||
RelayEvent,
|
||||
} from "@/shared/api/types";
|
||||
import { getMarkdownParseCount } from "@/shared/ui/markdown/nodeCache";
|
||||
import { syncAgentTurnsFromEvents } from "@/features/agents/activeAgentTurnsStore";
|
||||
import { recordTimeoutFromRejection } from "@/features/moderation/lib/timeoutStore";
|
||||
@@ -771,7 +775,7 @@ type RawFeedItem = {
|
||||
// backend always emits the key, as `null` when unknown.
|
||||
channel_type?: string | null;
|
||||
tags: string[][];
|
||||
category: "mention" | "needs_action" | "activity" | "agent_activity";
|
||||
category: FeedItemCategory;
|
||||
};
|
||||
|
||||
type RawHomeFeedResponse = {
|
||||
|
||||
Reference in New Issue
Block a user