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..e55346b4 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,34 @@ 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 { + if let Err(e) = validate_uuid(&p.channel_id) { + return format!("Error: {e}"); + } + 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-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-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 5d286d32..8a185940 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 9440a9f3..3eac07fd 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 + edit routes .route( "/api/messages/{event_id}", 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/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..c58b846b 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,23 @@ export function AppShell() { [queryClient], ); + const handleHideDm = React.useCallback( + 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"); + }); + } + }, + [hideDmMutation, selectedChannel?.id], + ); + const handleOpenSettings = React.useCallback( (section: SettingsSection = DEFAULT_SETTINGS_SECTION) => { setIsSearchOpen(false); @@ -576,6 +595,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..b1a2a951 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"; @@ -9,6 +9,7 @@ import { SidebarGroupContent, SidebarGroupLabel, SidebarMenu, + SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, } from "@/shared/ui/sidebar"; @@ -161,7 +162,7 @@ export function ChannelMenuButton({ {hasUnread && !isActive ? (