From 0f4fd5aa769a271a252a9b5f7ea53bb4740e4108 Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 21 Mar 2026 14:22:10 -0700 Subject: [PATCH 1/2] feat: add hide/close DM support (Slack-style DM management) - Add hidden_at column to channel_members for per-user DM hiding - Add POST /api/dms/{channel_id}/hide REST endpoint - Filter hidden DMs from GET /api/channels response - Clear hidden_at when re-opening a DM via POST /api/dms - Add hide_dm MCP tool for agent access - Add Tauri hide_dm command and React UI with close button on DM sidebar items - Optimistic cache update for instant UI feedback --- crates/sprout-db/src/channel.rs | 1 + crates/sprout-db/src/dm.rs | 53 +++++++++++++++++++ crates/sprout-db/src/lib.rs | 13 +++++ crates/sprout-mcp/src/server.rs | 32 +++++++++++ crates/sprout-relay/src/api/dms.rs | 49 +++++++++++++++++ crates/sprout-relay/src/api/mod.rs | 2 +- crates/sprout-relay/src/router.rs | 1 + desktop/scripts/check-file-sizes.mjs | 2 +- desktop/src-tauri/src/commands/dms.rs | 12 ++++- desktop/src-tauri/src/lib.rs | 1 + desktop/src/app/AppShell.tsx | 15 ++++++ desktop/src/features/channels/hooks.ts | 25 +++++++++ .../src/features/sidebar/ui/AppSidebar.tsx | 3 ++ .../features/sidebar/ui/SidebarSection.tsx | 23 +++++++- desktop/src/shared/api/tauri.ts | 4 ++ schema/schema.sql | 1 + 16 files changed, 232 insertions(+), 5 deletions(-) diff --git a/crates/sprout-db/src/channel.rs b/crates/sprout-db/src/channel.rs index 798b9637..38936d9d 100644 --- a/crates/sprout-db/src/channel.rs +++ b/crates/sprout-db/src/channel.rs @@ -730,6 +730,7 @@ pub async fn get_accessible_channels( ON c.id = cm.channel_id AND cm.pubkey = $1 AND cm.removed_at IS NULL WHERE c.deleted_at IS NULL {membership_clause} + AND (c.channel_type != 'dm' OR cm.hidden_at IS NULL) "# ); diff --git a/crates/sprout-db/src/dm.rs b/crates/sprout-db/src/dm.rs index 37ad6b1b..70c1054d 100644 --- a/crates/sprout-db/src/dm.rs +++ b/crates/sprout-db/src/dm.rs @@ -243,6 +243,7 @@ pub async fn list_dms_for_user( ON c.id = cm.channel_id AND cm.pubkey = $1 AND cm.removed_at IS NULL + AND cm.hidden_at IS NULL WHERE c.channel_type = 'dm' AND c.deleted_at IS NULL AND c.updated_at < $2 @@ -264,6 +265,7 @@ pub async fn list_dms_for_user( ON c.id = cm.channel_id AND cm.pubkey = $1 AND cm.removed_at IS NULL + AND cm.hidden_at IS NULL WHERE c.channel_type = 'dm' AND c.deleted_at IS NULL ORDER BY c.updated_at DESC @@ -350,6 +352,8 @@ pub async fn open_dm( // Check for existing DM first (fast path, no transaction). if let Some(existing) = find_dm_by_participants(pool, &hash).await? { + // Clear hidden_at for the caller so the DM reappears in their sidebar. + unhide_dm(pool, existing.id, created_by).await?; return Ok((existing, false)); } @@ -359,6 +363,55 @@ pub async fn open_dm( Ok((channel, true)) } +// -- Hide / unhide ------------------------------------------------------------ + +/// Hide a DM for a specific user by setting `hidden_at = NOW()`. +/// +/// The DM is not deleted — it can be restored by opening a new DM with the +/// same participants (which clears `hidden_at`). Returns an error if the user +/// is not an active member of the channel. +pub async fn hide_dm(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result<()> { + let result = sqlx::query( + r#" + UPDATE channel_members + SET hidden_at = NOW() + WHERE channel_id = $1 AND pubkey = $2 AND removed_at IS NULL + "#, + ) + .bind(channel_id) + .bind(pubkey) + .execute(pool) + .await?; + + if result.rows_affected() == 0 { + return Err(DbError::NotFound(format!( + "no active membership for channel {channel_id}" + ))); + } + + Ok(()) +} + +/// Unhide a DM for a specific user by clearing `hidden_at`. +/// +/// This is called automatically when a user re-opens a DM via [`open_dm`]. +/// It is a no-op if the membership is not currently hidden. +pub async fn unhide_dm(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result<()> { + sqlx::query( + r#" + UPDATE channel_members + SET hidden_at = NULL + WHERE channel_id = $1 AND pubkey = $2 AND removed_at IS NULL + "#, + ) + .bind(channel_id) + .bind(pubkey) + .execute(pool) + .await?; + + Ok(()) +} + // -- Row mapping -------------------------------------------------------------- fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result { diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index ca01f76f..b600a713 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -548,6 +548,19 @@ impl Db { dm::open_dm(&self.pool, pubkeys, created_by).await } + /// Hide a DM channel for a specific user. + /// + /// The DM is not deleted — it can be restored by opening a new DM with + /// the same participants. + pub async fn hide_dm(&self, channel_id: Uuid, pubkey: &[u8]) -> Result<()> { + dm::hide_dm(&self.pool, channel_id, pubkey).await + } + + /// Unhide a DM channel for a specific user. + pub async fn unhide_dm(&self, channel_id: Uuid, pubkey: &[u8]) -> Result<()> { + dm::unhide_dm(&self.pool, channel_id, pubkey).await + } + // ── Threads ────────────────────────────────────────────────────────────── /// Insert thread metadata. diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index f70078db..4eb733e2 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -409,6 +409,13 @@ pub struct AddDmMemberParams { pub pubkey: String, } +/// Parameters for the `hide_dm` tool. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct HideDmParams { + /// UUID of the DM channel to hide. + pub channel_id: String, +} + // ── Reaction tool parameter structs ────────────────────────────────────────── /// Parameters for the `add_reaction` tool. @@ -1662,6 +1669,31 @@ with kind:45003 comments)." } } + /// Hide a DM channel from the agent's DM list. + #[tool( + name = "hide_dm", + description = "Hide a direct message channel from the agent's DM list. The DM can be restored by opening a new DM with the same participants." + )] + pub async fn hide_dm(&self, Parameters(p): Parameters) -> String { + match self + .client + .post( + &format!("/api/dms/{}/hide", p.channel_id), + &serde_json::json!({}), + ) + .await + { + Ok(b) => { + if b.is_empty() { + "DM hidden successfully.".to_string() + } else { + b + } + } + Err(e) => format!("Error: {e}"), + } + } + // ── Reaction tools ──────────────────────────────────────────────────────── /// Add an emoji reaction to a message. diff --git a/crates/sprout-relay/src/api/dms.rs b/crates/sprout-relay/src/api/dms.rs index b49eafc4..66414160 100644 --- a/crates/sprout-relay/src/api/dms.rs +++ b/crates/sprout-relay/src/api/dms.rs @@ -3,6 +3,7 @@ //! Endpoints: //! POST /api/dms — Open or create a DM (idempotent) //! POST /api/dms/{channel_id}/members — Add member to group DM (creates new DM) +//! POST /api/dms/{channel_id}/hide — Hide a DM from the user's sidebar //! GET /api/dms — List user's DM conversations use std::sync::Arc; @@ -362,6 +363,54 @@ pub async fn list_dms_handler( }))) } +/// `POST /api/dms/{channel_id}/hide` — Hide a DM from the caller's sidebar. +/// +/// The DM is not deleted — it can be restored by opening a new DM with the +/// same participants. Returns 204 No Content on success. +pub async fn hide_dm_handler( + State(state): State>, + headers: HeaderMap, + Path(channel_id_str): Path, +) -> Result)> { + let ctx = extract_auth_context(&headers, &state).await?; + sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite) + .map_err(super::scope_error)?; + + let channel_id: Uuid = channel_id_str + .parse() + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id format"))?; + + // Verify the channel exists and is a DM. + let channel = state + .db + .get_channel(channel_id) + .await + .map_err(|_| super::not_found("DM not found"))?; + + if channel.channel_type != "dm" { + return Err(api_error(StatusCode::BAD_REQUEST, "channel is not a DM")); + } + + // Verify caller is a member. + let is_member = state + .db + .is_member(channel_id, &ctx.pubkey_bytes) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))?; + + if !is_member { + return Err(super::forbidden("not a member of this DM")); + } + + state + .db + .hide_dm(channel_id, &ctx.pubkey_bytes) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))?; + + Ok(StatusCode::NO_CONTENT) +} + // ── Helpers ─────────────────────────────────────────────────────────────────── /// Fetch and format participant info for a DM channel. diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index 25699df4..2f582f9e 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -58,7 +58,7 @@ pub use channels_metadata::{ archive_channel_handler, delete_channel_handler, get_channel_handler, set_purpose_handler, set_topic_handler, unarchive_channel_handler, update_channel_handler, }; -pub use dms::{add_dm_member_handler, list_dms_handler, open_dm_handler}; +pub use dms::{add_dm_member_handler, hide_dm_handler, list_dms_handler, open_dm_handler}; pub use events::get_event; pub use feed::feed_handler; pub use members::{add_members, join_channel, leave_channel, list_members, remove_member}; diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index dfa1c85a..d42c7bf1 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -146,6 +146,7 @@ pub fn build_router(state: Arc) -> Router { "/api/dms/{channel_id}/members", post(api::add_dm_member_handler), ) + .route("/api/dms/{channel_id}/hide", post(api::hide_dm_handler)) // Message delete route .route("/api/messages/{event_id}", delete(api::delete_message)) // Reaction routes diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 17ba8177..aeebb714 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -33,7 +33,7 @@ const overrides = new Map([ ["src-tauri/src/managed_agents/persona_card.rs", 700], // PNG/ZIP persona card codec + 21 unit tests (~300 lines of tests) ["src/app/AppShell.tsx", 775], ["src/features/agents/ui/AgentsView.tsx", 625], // persona/team orchestration plus import/export wiring - ["src/features/channels/hooks.ts", 525], // canvas query + mutation hooks + ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], ["src/features/messages/ui/MessageComposer.tsx", 665], // media upload handlers (paste, drop, dialog) + channelId reset effect ["src/features/settings/ui/SettingsView.tsx", 600], diff --git a/desktop/src-tauri/src/commands/dms.rs b/desktop/src-tauri/src/commands/dms.rs index dac3c596..f600ff5e 100644 --- a/desktop/src-tauri/src/commands/dms.rs +++ b/desktop/src-tauri/src/commands/dms.rs @@ -4,7 +4,7 @@ use tauri::State; use crate::{ app_state::AppState, models::{ChannelInfo, OpenDmBody, OpenDmResponse}, - relay::{build_authed_request, send_json_request}, + relay::{build_authed_request, send_empty_request, send_json_request}, }; #[tauri::command] @@ -20,3 +20,13 @@ pub async fn open_dm( let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; send_json_request(request).await } + +#[tauri::command] +pub async fn hide_dm( + channel_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let path = format!("/api/dms/{channel_id}/hide"); + let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?; + send_empty_request(request).await +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 02c2336a..d4954997 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -209,6 +209,7 @@ pub fn run() { get_channels, create_channel, open_dm, + hide_dm, get_channel_details, get_channel_members, update_channel, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 7d718ef3..533b0e4f 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -10,6 +10,7 @@ import { ChatHeader } from "@/features/chat/ui/ChatHeader"; import { channelsQueryKey, useCreateChannelMutation, + useHideDmMutation, useOpenDmMutation, useChannelsQuery, useSelectedChannel, @@ -107,6 +108,7 @@ export function AppShell() { const createChannelMutation = useCreateChannelMutation(); const createForumMutation = useCreateChannelMutation(); const openDmMutation = useOpenDmMutation(); + const hideDmMutation = useHideDmMutation(); const activeChannel = selectedView === "channel" ? selectedChannel : null; const activeChannelId = activeChannel?.id ?? null; const messagesQuery = useChannelMessagesQuery(activeChannel); @@ -305,6 +307,18 @@ export function AppShell() { [queryClient], ); + const handleHideDm = React.useCallback( + (channelId: string) => { + void hideDmMutation.mutateAsync(channelId); + if (selectedChannel?.id === channelId) { + React.startTransition(() => { + setSelectedView("home"); + }); + } + }, + [hideDmMutation, selectedChannel?.id], + ); + const handleOpenSettings = React.useCallback( (section: SettingsSection = DEFAULT_SETTINGS_SECTION) => { setIsSearchOpen(false); @@ -576,6 +590,7 @@ export function AppShell() { setIsSearchOpen(true); void refetchChannels(); }} + onHideDm={handleHideDm} onOpenDm={async ({ pubkeys }) => { const directMessage = await openDmMutation.mutateAsync({ pubkeys, diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index c4a39218..89e4bad3 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -15,6 +15,7 @@ import { getChannelDetails, getChannelMembers, getChannels, + hideDm, joinChannel, leaveChannel, openDm, @@ -197,6 +198,30 @@ export function useOpenDmMutation() { }); } +export function useHideDmMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (channelId: string) => hideDm(channelId), + onMutate: async (channelId) => { + await queryClient.cancelQueries({ queryKey: channelsQueryKey }); + const previous = queryClient.getQueryData(channelsQueryKey); + queryClient.setQueryData(channelsQueryKey, (current = []) => + current.filter((channel) => channel.id !== channelId), + ); + return { previous }; + }, + onError: (_error, _channelId, context) => { + if (context?.previous) { + queryClient.setQueryData(channelsQueryKey, context.previous); + } + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + }, + }); +} + export function useChannelDetailsQuery( channelId: string | null, enabled = true, diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index b49cbed4..bf434637 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -77,6 +77,7 @@ type AppSidebarProps = { onOpenBrowseChannels: () => void; onOpenBrowseForums: () => void; onOpenSearch: () => void; + onHideDm: (channelId: string) => void; onOpenDm: (input: { pubkeys: string[] }) => Promise; onSelectAgents: () => void; onSelectHome: () => void; @@ -440,6 +441,7 @@ export function AppSidebar({ onOpenBrowseChannels, onOpenBrowseForums, onOpenSearch, + onHideDm, onOpenDm, onSelectAgents, onSelectHome, @@ -640,6 +642,7 @@ export function AppSidebar({ isActiveChannel={selectedView === "channel"} items={directMessages} channelLabels={dmChannelLabels} + onHideDm={onHideDm} onSelectChannel={onSelectChannel} presenceByChannelId={dmPresenceByChannelId} selectedChannelId={selectedChannelId} diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index a6eec465..de656653 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -1,5 +1,5 @@ import type * as React from "react"; -import { CircleDot, FileText, Hash, Lock } from "lucide-react"; +import { CircleDot, FileText, Hash, Lock, X } from "lucide-react"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { Channel, PresenceStatus } from "@/shared/api/types"; @@ -127,6 +127,7 @@ export function ChannelMenuButton({ hasUnread, dmParticipants, presenceStatus, + onHideDm, onSelectChannel, }: { channel: Channel; @@ -135,6 +136,7 @@ export function ChannelMenuButton({ hasUnread: boolean; dmParticipants?: SidebarDmParticipant[]; presenceStatus?: PresenceStatus; + onHideDm?: (channelId: string) => void; onSelectChannel: (channelId: string) => void; }) { const resolvedLabel = label ?? channel.name; @@ -165,6 +167,20 @@ export function ChannelMenuButton({ data-testid={`channel-unread-${channel.name}`} /> ) : null} + {channel.channelType === "dm" && onHideDm ? ( + + ) : null} ); } @@ -181,6 +197,7 @@ export function SidebarSection({ title, testId, unreadChannelIds, + onHideDm, onSelectChannel, }: { action?: React.ReactNode; @@ -194,6 +211,7 @@ export function SidebarSection({ title: string; testId: string; unreadChannelIds: Set; + onHideDm?: (channelId: string) => void; onSelectChannel: (channelId: string) => void; }) { if (items.length === 0 && !action && !emptyState) { @@ -208,7 +226,7 @@ export function SidebarSection({ {items.length > 0 ? ( {items.map((channel) => ( - + diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 38865c69..9b445c8a 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -535,6 +535,10 @@ export async function openDm(input: OpenDmInput): Promise { return fromRawChannel(await invokeTauri("open_dm", input)); } +export async function hideDm(channelId: string): Promise { + await invokeTauri("hide_dm", { channelId }); +} + export async function getChannelDetails( channelId: string, ): Promise { diff --git a/schema/schema.sql b/schema/schema.sql index 31b43c20..e5e82ae6 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -57,6 +57,7 @@ CREATE TABLE channel_members ( invited_by BYTEA, removed_at TIMESTAMPTZ, removed_by BYTEA, + hidden_at TIMESTAMPTZ, PRIMARY KEY (channel_id, pubkey) ); From 448862c9671469001c508d637472d7d9b9392915 Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Sat, 21 Mar 2026 21:31:51 -0400 Subject: [PATCH 2/2] fix: address crossfire review findings for hide DM feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Register hide_dm in MCP ALL_TOOLS (dms toolset) to fix toolset gating leak - Add validate_uuid guard to hide_dm MCP tool for consistent error handling - Replace nested button with SidebarMenuAction sibling (valid HTML) - Await mutation in handleHideDm before navigating (complete rollback) - Add hide_dm handler to e2e test bridge - Hide unread dot on hover when close button appears - Update MCP tool count assertions (42 → 43) in toolsets + e2e tests --- crates/sprout-mcp/src/server.rs | 3 ++ crates/sprout-mcp/src/toolsets.rs | 9 +++--- crates/sprout-test-client/tests/e2e_mcp.rs | 9 +++--- desktop/src/app/AppShell.tsx | 9 ++++-- .../features/sidebar/ui/SidebarSection.tsx | 30 ++++++++----------- desktop/src/testing/e2eBridge.ts | 27 +++++++++++++++++ 6 files changed, 59 insertions(+), 28 deletions(-) diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 4eb733e2..e55346b4 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -1675,6 +1675,9 @@ with kind:45003 comments)." description = "Hide a direct message channel from the agent's DM list. The DM can be restored by opening a new DM with the same participants." )] pub async fn hide_dm(&self, Parameters(p): Parameters) -> String { + if let Err(e) = validate_uuid(&p.channel_id) { + return format!("Error: {e}"); + } match self .client .post( diff --git a/crates/sprout-mcp/src/toolsets.rs b/crates/sprout-mcp/src/toolsets.rs index bcea5aca..516bbd3b 100644 --- a/crates/sprout-mcp/src/toolsets.rs +++ b/crates/sprout-mcp/src/toolsets.rs @@ -21,7 +21,7 @@ //! |-----------------|-------| //! | `default` | 25 | //! | `channel_admin` | 6 | -//! | `dms` | 2 | +//! | `dms` | 3 | //! | `canvas` | 2 | //! | `workflow_admin`| 5 | //! | `identity` | 1 | @@ -40,7 +40,7 @@ use std::sync::LazyLock; /// classification. `is_read = true` means the tool is safe to include under /// a `:ro` (read-only) mode restriction. /// -/// 42 tools total. See [`DEFERRED_TOOLS`] for tools planned but not yet implemented. +/// 43 tools total. See [`DEFERRED_TOOLS`] for tools planned but not yet implemented. pub const ALL_TOOLS: &[(&str, &str, bool)] = &[ // ── default ───────────────────────────────────────────────────────────── ("send_message", "default", false), @@ -77,6 +77,7 @@ pub const ALL_TOOLS: &[(&str, &str, bool)] = &[ ("list_channel_members", "channel_admin", true), // ── dms ────────────────────────────────────────────────────────────────── ("add_dm_member", "dms", false), + ("hide_dm", "dms", false), ("list_dms", "dms", true), // ── canvas ─────────────────────────────────────────────────────────────── ("get_canvas", "canvas", true), @@ -370,8 +371,8 @@ mod tests { } #[test] - fn all_tools_count_is_42() { - assert_eq!(ALL_TOOLS.len(), 42); + fn all_tools_count_is_43() { + assert_eq!(ALL_TOOLS.len(), 43); } #[test] diff --git a/crates/sprout-test-client/tests/e2e_mcp.rs b/crates/sprout-test-client/tests/e2e_mcp.rs index 9bc6d74e..b8e9b750 100644 --- a/crates/sprout-test-client/tests/e2e_mcp.rs +++ b/crates/sprout-test-client/tests/e2e_mcp.rs @@ -102,7 +102,7 @@ fn spawn_mcp_server(keys: &Keys) -> Child { ]) .env("SPROUT_RELAY_URL", relay_ws_url()) .env("SPROUT_PRIVATE_KEY", &nsec) - // Tests exercise all 42 tools — enable every toolset. + // Tests exercise all 43 tools — enable every toolset. .env("SPROUT_TOOLSETS", "all") // Prevent a stale SPROUT_API_TOKEN from the host .env leaking into // the subprocess and causing NIP-42 auth failures against a fresh DB. @@ -251,7 +251,7 @@ impl McpSession { // ── Tests ───────────────────────────────────────────────────────────────────── /// Spawn the MCP server, complete the initialize handshake, and verify that -/// all 42 expected tools are listed by `tools/list`. +/// all 43 expected tools are listed by `tools/list`. #[tokio::test] #[ignore] async fn test_mcp_initialize_and_list_tools() { @@ -300,8 +300,8 @@ async fn test_mcp_initialize_and_list_tools() { assert_eq!( tools.len(), - 42, - "expected exactly 42 tools, got {}. Tools: {:?}", + 43, + "expected exactly 43 tools, got {}. Tools: {:?}", tools.len(), tools .iter() @@ -332,6 +332,7 @@ async fn test_mcp_initialize_and_list_tools() { "get_thread", "get_users", "get_workflow_runs", + "hide_dm", "join_channel", "leave_channel", "list_channel_members", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 533b0e4f..c58b846b 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -308,8 +308,13 @@ export function AppShell() { ); const handleHideDm = React.useCallback( - (channelId: string) => { - void hideDmMutation.mutateAsync(channelId); + async (channelId: string) => { + try { + await hideDmMutation.mutateAsync(channelId); + } catch { + // Optimistic rollback handled by onError in the mutation hook. + return; + } if (selectedChannel?.id === channelId) { React.startTransition(() => { setSelectedView("home"); diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index de656653..b1a2a951 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -9,6 +9,7 @@ import { SidebarGroupContent, SidebarGroupLabel, SidebarMenu, + SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, } from "@/shared/ui/sidebar"; @@ -127,7 +128,6 @@ export function ChannelMenuButton({ hasUnread, dmParticipants, presenceStatus, - onHideDm, onSelectChannel, }: { channel: Channel; @@ -136,7 +136,6 @@ export function ChannelMenuButton({ hasUnread: boolean; dmParticipants?: SidebarDmParticipant[]; presenceStatus?: PresenceStatus; - onHideDm?: (channelId: string) => void; onSelectChannel: (channelId: string) => void; }) { const resolvedLabel = label ?? channel.name; @@ -163,24 +162,10 @@ export function ChannelMenuButton({ {hasUnread && !isActive ? ( ))} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index e0aed30f..96a92f7f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1715,6 +1715,28 @@ async function handleOpenDm( ); } +async function handleHideDm( + args: { channelId: string }, + config: E2eConfig | undefined, +) { + const identity = getIdentity(config); + if (!identity) { + const index = mockChannels.findIndex( + (channel) => channel.id === args.channelId, + ); + if (index === -1) { + throw new Error(`DM ${args.channelId} not found.`); + } + // Remove from mock list (simulates hiding from sidebar). + mockChannels.splice(index, 1); + return; + } + + await relayEmptyRequest(config, `/api/dms/${args.channelId}/hide`, { + method: "POST", + }); +} + async function handleGetChannelDetails( args: { channelId: string }, config: E2eConfig | undefined, @@ -3286,6 +3308,11 @@ export function maybeInstallE2eTauriMocks() { payload as Parameters[0], activeConfig, ); + case "hide_dm": + return handleHideDm( + payload as Parameters[0], + activeConfig, + ); case "get_channel_details": return handleGetChannelDetails( payload as Parameters[0],