diff --git a/desktop/src/features/channels/lib/dmHuddleMembers.ts b/desktop/src/features/channels/lib/dmHuddleMembers.ts new file mode 100644 index 000000000..4da722120 --- /dev/null +++ b/desktop/src/features/channels/lib/dmHuddleMembers.ts @@ -0,0 +1,54 @@ +import type { Channel } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export function getDmHuddleMemberPubkeys( + channel: Channel | null, + agentPubkeys: ReadonlySet | undefined, + currentPubkey: string | undefined, +) { + if (channel?.channelType !== "dm" || !agentPubkeys) { + return []; + } + + const normalizedCurrentPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + const seen = new Set(); + + return channel.participantPubkeys.filter((pubkey) => { + const normalizedPubkey = normalizePubkey(pubkey); + if ( + normalizedCurrentPubkey && + normalizedPubkey === normalizedCurrentPubkey + ) { + return false; + } + + if (!agentPubkeys.has(normalizedPubkey) || seen.has(normalizedPubkey)) { + return false; + } + + seen.add(normalizedPubkey); + return true; + }); +} + +export function hasOtherDmParticipant( + channel: Channel | null, + currentPubkey: string | undefined, +) { + if (channel?.channelType !== "dm") { + return false; + } + + const normalizedCurrentPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + + return channel.participantPubkeys.some((pubkey) => { + const normalizedPubkey = normalizePubkey(pubkey); + return ( + !normalizedCurrentPubkey || normalizedPubkey !== normalizedCurrentPubkey + ); + }); +} diff --git a/desktop/src/features/channels/ui/ChannelHeaderStatusBadge.tsx b/desktop/src/features/channels/ui/ChannelHeaderStatusBadge.tsx index a1d7d7551..14942db04 100644 --- a/desktop/src/features/channels/ui/ChannelHeaderStatusBadge.tsx +++ b/desktop/src/features/channels/ui/ChannelHeaderStatusBadge.tsx @@ -1,38 +1,18 @@ import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel"; import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge"; -import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; -import type { Channel, PresenceStatus } from "@/shared/api/types"; type ChannelHeaderStatusBadgeProps = { - channelType?: Channel["channelType"]; ephemeralDisplay: EphemeralChannelDisplay | null; - presenceStatus: PresenceStatus | null; }; export function ChannelHeaderStatusBadge({ - channelType, ephemeralDisplay, - presenceStatus, }: ChannelHeaderStatusBadgeProps) { - const ephemeralBadge = ephemeralDisplay ? ( + return ephemeralDisplay ? ( ) : null; - - if (channelType === "dm" && presenceStatus) { - return ( - <> - - {ephemeralBadge} - - ); - } - - return ephemeralBadge; } diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index c0c5b19b0..5296707ac 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -13,6 +13,10 @@ import { } from "@/features/messages/ui/MessageTimeline"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { buildDirectMessageIntro } from "@/features/channels/lib/dmParticipantDisplay"; +import { + getDmHuddleMemberPubkeys, + hasOtherDmParticipant, +} from "@/features/channels/lib/dmHuddleMembers"; import { buildVideoReviewCommentsByRootId, buildVideoReviewContextForMessage, @@ -69,6 +73,7 @@ type ChannelPaneProps = { activeChannel: Channel | null; activityAgents?: BotActivityAgent[]; agentPubkeys?: ReadonlySet; + agentPubkeysPending?: boolean; agentSessionAgents: ChannelAgentSessionAgent[]; botTypingEntries: TypingIndicatorEntry[]; channelFind: ReturnType; @@ -180,6 +185,7 @@ type ChannelPaneProps = { export const ChannelPane = React.memo(function ChannelPane({ activeChannel, agentPubkeys, + agentPubkeysPending = false, agentSessionAgents, activityAgents = agentSessionAgents, botTypingEntries, @@ -274,6 +280,12 @@ export const ChannelPane = React.memo(function ChannelPane({ !activeChannel.archivedAt; const hasMainComposerOverlay = !isNonMemberView; const activeChannelId = activeChannel?.id ?? null; + const huddleMemberPubkeys = React.useMemo( + () => getDmHuddleMemberPubkeys(activeChannel, agentPubkeys, currentPubkey), + [activeChannel, agentPubkeys, currentPubkey], + ); + const huddleMemberPubkeysPending = + agentPubkeysPending && hasOtherDmParticipant(activeChannel, currentPubkey); const isActiveWelcomeChannel = activeChannel !== null && isWelcomeChannel(activeChannel); useComposerHeightPadding( @@ -677,6 +689,8 @@ export const ChannelPane = React.memo(function ChannelPane({ followThreadById={followThreadById} hasComposerOverlay={hasMainComposerOverlay} hasOlderMessages={hasOlderMessages} + huddleMemberPubkeys={huddleMemberPubkeys} + huddleMemberPubkeysPending={huddleMemberPubkeysPending} isFetchingOlder={isFetchingOlder} isFollowingThreadById={isFollowingThreadById} isMessageUnreadById={isMessageUnreadById} @@ -842,6 +856,8 @@ export const ChannelPane = React.memo(function ChannelPane({ disabled={isComposerDisabled} editTarget={threadEditTarget} firstUnreadReplyId={threadFirstUnreadReplyId} + huddleMemberPubkeys={huddleMemberPubkeys} + huddleMemberPubkeysPending={huddleMemberPubkeysPending} isFollowingThread={isFollowingThread} isMessageUnreadById={isMessageUnreadById} isSending={isSending} diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 13164cf5e..ff95563e4 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -311,8 +311,21 @@ export function ChannelScreen({ for (const agent of relayAgents) { pubkeys.add(normalizePubkey(agent.pubkey)); } + for (const [pubkey, profile] of Object.entries( + messageProfilesQuery.data?.profiles ?? {}, + )) { + if (profile.isAgent) { + pubkeys.add(normalizePubkey(pubkey)); + } + } return pubkeys; - }, [channelMembers, managedAgents, relayAgents]); + }, [channelMembers, managedAgents, messageProfilesQuery.data, relayAgents]); + const agentPubkeysPending = + activeChannel?.channelType === "dm" && + (channelMembersQuery.isPending || + managedAgentsQuery.isPending || + relayAgentsQuery.isPending || + (messageProfilePubkeys.length > 0 && messageProfilesQuery.isPending)); const { agentSessionCandidates, botTypingEntries, @@ -758,6 +771,7 @@ export function ChannelScreen({ activeChannel={activeChannel} activityAgents={channelAgentSessionAgents} agentPubkeys={agentPubkeys} + agentPubkeysPending={agentPubkeysPending} agentSessionAgents={agentSessionAgents} botTypingEntries={botTypingEntries} channelFind={channelFind} diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 311d22449..b971d584b 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -8,11 +8,21 @@ import { getChannelDescription } from "@/features/channels/lib/channelDescriptio import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay"; import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge"; import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar"; -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { + DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY, + ProfileAvatarWithStatus, + scaleProfileAvatarStatusGeometry, +} from "@/features/profile/ui/ProfileAvatarWithStatus"; import { Button } from "@/shared/ui/button"; import type { Channel, PresenceStatus } from "@/shared/api/types"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +const DM_HEADER_AVATAR_SIZE = 32; +const DM_HEADER_AVATAR_STATUS_GEOMETRY = scaleProfileAvatarStatusGeometry( + DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY, + DM_HEADER_AVATAR_SIZE, +); + type ChannelScreenHeaderProps = { activeChannel: Channel | null; activeChannelEphemeralDisplay: EphemeralChannelDisplay | null; @@ -102,11 +112,16 @@ export function ChannelScreenHeader({ participants={activeDmHeaderParticipants} /> ) : ( - ) @@ -114,9 +129,7 @@ export function ChannelScreenHeader({ } statusBadge={ } title={activeChannelTitle} @@ -137,7 +150,7 @@ function DmHeaderParticipantStack({ return ( diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 39d8b46dc..814030ef4 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -120,7 +120,7 @@ export function ChatHeader({
-
+
{leadingContent ?? (

diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index 6d1f28bd1..1afae6f64 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -50,6 +50,7 @@ export type InboxTypeLabel = { export type InboxReply = { authorLabel: string; + authorPubkey: string; avatarUrl: string | null; content: string; depth?: number; diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index cf2974c85..8c5b60c25 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -49,6 +49,7 @@ import type { HomeFeedResponse } from "@/shared/api/types"; import { KIND_REACTION } from "@/shared/constants/kinds"; import { topChromeInset } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; import { useElementWidth } from "@/shared/hooks/use-mobile"; import { @@ -212,6 +213,21 @@ export function HomeView({ enabled: feedProfilePubkeys.length > 0, }); const feedProfiles = feedProfilesQuery.data?.profiles; + const inboxAgentPubkeys = React.useMemo(() => { + const pubkeys = new Set(); + + for (const item of feed?.feed.agentActivity ?? []) { + pubkeys.add(normalizePubkey(item.pubkey)); + } + + for (const [pubkey, profile] of Object.entries(feedProfiles ?? {})) { + if (profile.isAgent) { + pubkeys.add(normalizePubkey(pubkey)); + } + } + + return pubkeys; + }, [feed?.feed.agentActivity, feedProfiles]); const inboxItems = React.useMemo( () => buildInboxItems({ @@ -279,9 +295,12 @@ export function HomeView({ return timelineMessages.map((message) => { const event = eventById.get(message.id); + const authorPubkey = + message.pubkey ?? event?.pubkey ?? selectedItem.item.pubkey; return { id: message.id, authorLabel: message.author, + authorPubkey, avatarUrl: message.avatarUrl ?? null, content: message.body, depth: event ? getContextMessageDepth(event, eventById) : message.depth, @@ -457,6 +476,7 @@ export function HomeView({ {showListPane ? ( { }); type InboxDetailPaneProps = { + agentPubkeys?: ReadonlySet; canDelete: boolean; canOpenChannel: boolean; canReply: boolean; @@ -69,6 +70,7 @@ type InboxDetailPaneProps = { }; export function InboxDetailPane({ + agentPubkeys, canDelete, canOpenChannel, canReply, @@ -185,6 +187,7 @@ export function InboxDetailPane({ : [ { authorLabel: item.senderLabel, + authorPubkey: item.item.pubkey, avatarUrl: item.avatarUrl, content: item.preview, depth: 0, @@ -323,6 +326,7 @@ export function InboxDetailPane({
) : null} ; + agentPubkeys?: ReadonlySet; doneSet: ReadonlySet; filter: InboxFilter; items: InboxItem[]; @@ -115,6 +118,7 @@ type InboxListPaneProps = { export function InboxListPane({ activeReminderEventIds, + agentPubkeys, doneSet, filter, items, @@ -153,6 +157,9 @@ export function InboxListPane({ const hasActiveReminder = activeReminderEventIds?.has(item.id) ?? false; const hasChannelTarget = Boolean(item.item.channelId); const typeLabel = getInboxTypeLabel(item); + const isSenderAgent = + agentPubkeys?.has(normalizePubkey(item.item.pubkey)) === true; + const profileRole = isSenderAgent ? "bot" : undefined; const rowHighlightColor = isSelected ? "color-mix(in srgb, hsl(var(--background)) 70%, hsl(var(--muted)) 30%)" : "color-mix(in srgb, hsl(var(--background)) 75%, hsl(var(--muted)) 25%)"; @@ -186,19 +193,42 @@ export function InboxListPane({ />
- + + + + +
-

- {item.senderLabel} -

+ + + + {item.senderLabel} + + + ; canReply: boolean; /** Channel UUID for "Copy link" — passed straight through to MessageActionBar. */ channelId?: string | null; @@ -43,6 +47,7 @@ type InboxMessageRowProps = { }; export function InboxMessageRow({ + agentPubkeys, canReply, channelId = null, isFocusHighlightVisible, @@ -68,6 +73,9 @@ export function InboxMessageRow({ errorMessage: reactionErrorMessage, select: handleReactionSelect, } = useReactionHandler(timelineMessage, onToggleReaction); + const isAuthorAgent = + agentPubkeys?.has(normalizePubkey(message.authorPubkey)) === true; + const profileRole = isAuthorAgent ? "bot" : undefined; return (
@@ -114,19 +122,35 @@ export function InboxMessageRow({ ) : null}
- + + + + +
-

- {message.authorLabel} -

+ + + {message.authorLabel} + +

{message.fullTimestampLabel}

diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 4151b1b0b..5d62f1e70 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -118,7 +118,7 @@ export function mergeTimelineCacheMessages( ); } -function createOptimisticMessage( +export function createOptimisticMessage( channelId: string, content: string, identity: Identity, diff --git a/desktop/src/features/messages/lib/waveMessage.ts b/desktop/src/features/messages/lib/waveMessage.ts new file mode 100644 index 000000000..7ef320dd3 --- /dev/null +++ b/desktop/src/features/messages/lib/waveMessage.ts @@ -0,0 +1,26 @@ +export const WAVE_MESSAGE_MARKER = ""; + +export type WaveMessageContent = { + fallbackText: string; +}; + +export function buildWaveMessageContent(senderName: string): string { + const trimmedName = senderName.trim() || "Someone"; + return `${WAVE_MESSAGE_MARKER}\n${trimmedName} waved at you.`; +} + +export function parseWaveMessageContent( + content: string, +): WaveMessageContent | null { + const trimmedContent = content.trimStart(); + + if (!trimmedContent.startsWith(WAVE_MESSAGE_MARKER)) { + return null; + } + + const fallbackText = trimmedContent.slice(WAVE_MESSAGE_MARKER.length).trim(); + + return { + fallbackText: fallbackText || "Someone waved at you.", + }; +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 16c3ceed7..f877e6419 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -22,6 +22,7 @@ import { UserAvatar } from "@/shared/ui/UserAvatar"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { parseImetaTags } from "@/features/messages/lib/parseImeta"; import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji"; +import { parseWaveMessageContent } from "@/features/messages/lib/waveMessage"; import { resolveMentionNames, resolveMentionPubkeysByName, @@ -31,6 +32,7 @@ import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; import { MessageActionBar } from "./MessageActionBar"; import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; import { MessageTimestamp } from "./MessageTimestamp"; +import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; const DiffMessage = React.lazy(() => import("./DiffMessage")); @@ -54,6 +56,8 @@ export const MessageRow = React.memo( highlightReplyConnector = false, highlightThreadLineDepths, hoverBackground = true, + huddleMemberPubkeys, + huddleMemberPubkeysPending = false, actionBarPlacement = "floating", collapseDescendantsLabel, isFollowingThread, @@ -88,6 +92,8 @@ export const MessageRow = React.memo( highlightReplyConnector?: boolean; highlightThreadLineDepths?: ReadonlyArray; hoverBackground?: boolean; + huddleMemberPubkeys?: readonly string[]; + huddleMemberPubkeysPending?: boolean; actionBarPlacement?: "floating" | "inside"; collapseDescendantsLabel?: string; isFollowingThread?: boolean; @@ -155,6 +161,12 @@ export const MessageRow = React.memo( return pubkeys; }, [agentPubkeys, profiles]); + const profilePopoverRole = + message.role === "bot" || + (message.pubkey && + resolvedAgentPubkeys.has(normalizePubkey(message.pubkey))) + ? "bot" + : message.role; const agentMentionPubkeysByName = React.useMemo(() => { if (!mentionPubkeysByName) { return undefined; @@ -275,6 +287,20 @@ export const MessageRow = React.memo( ); default: + { + const waveMessage = parseWaveMessageContent(message.body); + if (waveMessage) { + return ( + + ); + } + } + return ( + + +
+ ) : null} + + ) : null}
diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 723e58f48..d435553b4 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -20,7 +20,11 @@ import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurn import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel"; import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge"; -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { + DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY, + ProfileAvatarWithStatus, + scaleProfileAvatarStatusGeometry, +} from "@/features/profile/ui/ProfileAvatarWithStatus"; import type { Channel, PresenceStatus } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { useNow } from "@/shared/lib/useNow"; @@ -33,8 +37,6 @@ import { SidebarMenuItem, } from "@/shared/ui/sidebar"; -import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; - const SECTION_LABEL_BUTTON_CLASS = "group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground"; const SECTION_LABEL_CHEVRON_CLASS = @@ -47,6 +49,11 @@ const SIDEBAR_ROW_ACTION_REPLACED_BADGE_CLASS = "max-md:opacity-0 md:group-focus-within/menu-item:opacity-0 md:group-hover/menu-item:opacity-0"; const SIDEBAR_ROW_ICON_ACTION_CLASS = "flex size-6 items-center justify-center p-1 text-sidebar-foreground/45 transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-sidebar-ring peer-data-[active=true]/menu-button:text-sidebar-active-foreground/75 peer-data-[active=true]/menu-button:hover:text-sidebar-active-foreground [&>svg]:size-4 [&>svg]:shrink-0"; +const DM_AVATAR_SIZE = 24; +const DM_AVATAR_STATUS_GEOMETRY = scaleProfileAvatarStatusGeometry( + DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY, + DM_AVATAR_SIZE, +); function formatUnreadCount(count: number): string { return count > 99 ? "99+" : String(count); @@ -151,7 +158,7 @@ function DmChannelIcon({ return (