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:
morgmart
2026-08-24 10:20:53 -07:00
committed by GitHub
parent 01091c15a1
commit db5617dd15
7 changed files with 145 additions and 13 deletions

View File

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

View File

@@ -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());
}

View File

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

View File

@@ -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"]);

View File

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

View File

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

View File

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