diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 49dd369bb2..51f9d50716 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -10,12 +10,13 @@ import { } from "lucide-react"; import type * as React from "react"; +import { cn } from "@/shared/lib/cn"; import type { ChannelType, ChannelVisibility } from "@/shared/api/types"; type ChatHeaderProps = { actions?: React.ReactNode; title: string; - description: string; + description?: string; channelType?: ChannelType; visibility?: ChannelVisibility; mode?: "home" | "channel" | "agents" | "workflows" | "pulse"; @@ -73,7 +74,10 @@ export function ChatHeader({ }: ChatHeaderProps) { return (
@@ -96,12 +100,14 @@ export function ChatHeader({ ) : null} -

- {description} -

+ {description ? ( +

+ {description} +

+ ) : null} {actions ?
{actions}
: null} diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index 97dfbe9ded..c2471d84fa 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -2,19 +2,31 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; -import type { FeedItem, HomeFeedResponse } from "@/shared/api/types"; +import { getThreadReference } from "@/features/messages/lib/threading"; +import type { + FeedItem, + FeedItemCategory, + HomeFeedResponse, +} from "@/shared/api/types"; import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; -export type InboxFilter = "all" | "mention" | "needs_action"; +export type InboxFilter = + | "all" + | "mention" + | "needs_action" + | "activity" + | "agent_activity"; export type InboxItem = { avatarUrl: string | null; id: string; item: FeedItem; + categories: FeedItemCategory[]; categoryLabel: string; channelLabel: string | null; fullTimestampLabel: string; isActionRequired: boolean; + latestActivityAt: number; mentionNames: string[]; preview: string; searchableText: string; @@ -126,6 +138,34 @@ function feedPreview(item: FeedItem) { return "No additional details were attached to this event."; } +function categoryLabelFor(category: FeedItemCategory) { + return category === "needs_action" + ? "Needs Action" + : category === "mention" + ? "Mention" + : category === "agent_activity" + ? "Agent update" + : "Activity"; +} + +function categoryPriority(category: FeedItemCategory) { + switch (category) { + case "needs_action": + return 0; + case "mention": + return 1; + case "agent_activity": + return 2; + case "activity": + return 3; + } +} + +function getInboxThreadKey(item: FeedItem) { + const thread = getThreadReference(item.tags); + return thread.rootId ?? thread.parentId ?? item.id; +} + function formatInboxTimestamp(unixSeconds: number) { const date = new Date(unixSeconds * 1_000); const now = new Date(); @@ -155,7 +195,7 @@ export function groupInboxItems(items: InboxItem[]): InboxGroup[] { const now = new Date(); for (const item of items) { - const date = new Date(item.item.createdAt * 1_000); + const date = new Date(item.latestActivityAt * 1_000); const dayDiff = diffInDays(now, date); const label = dayDiff === 0 @@ -190,46 +230,86 @@ export function buildInboxItems({ return []; } - const items = [...feed.feed.mentions, ...feed.feed.needsAction].sort( - (left, right) => right.createdAt - left.createdAt, - ); + const feedItems = [ + ...feed.feed.mentions, + ...feed.feed.needsAction, + ...feed.feed.activity, + ...feed.feed.agentActivity, + ]; - return items.map((item) => { - const senderLabel = resolveUserLabel({ - pubkey: item.pubkey, - currentPubkey, - profiles, - preferResolvedSelfLabel: true, - }); - const subject = feedHeadline(item); - const preview = feedPreview(item); - const mentionNames = resolveMentionNames(item.tags, profiles) ?? []; - const channelLabel = item.channelName.trim() || null; - const categoryLabel = - item.category === "needs_action" ? "Needs Action" : "Mention"; + const threadGroups = new Map< + string, + { + items: FeedItem[]; + latestActivityAt: number; + rootItem: FeedItem | null; + } + >(); - return { - avatarUrl: profiles?.[item.pubkey.toLowerCase()]?.avatarUrl ?? null, - id: item.id, - item, - categoryLabel, - channelLabel, - fullTimestampLabel: formatInboxFullTimestamp(item.createdAt), - isActionRequired: item.category === "needs_action", - mentionNames, - preview, - searchableText: [ + for (const item of feedItems) { + const threadKey = getInboxThreadKey(item); + const group = threadGroups.get(threadKey) ?? { + items: [], + latestActivityAt: 0, + rootItem: null, + }; + + group.items.push(item); + group.latestActivityAt = Math.max(group.latestActivityAt, item.createdAt); + if (item.id === threadKey) { + group.rootItem = item; + } + + threadGroups.set(threadKey, group); + } + + return [...threadGroups.values()] + .sort((left, right) => right.latestActivityAt - left.latestActivityAt) + .map((group) => { + const latestItem = group.items.reduce((latest, current) => + current.createdAt > latest.createdAt ? current : latest, + ); + const item = group.rootItem ?? latestItem; + const categories = [ + ...new Set(group.items.map((groupItem) => groupItem.category)), + ].sort((left, right) => categoryPriority(left) - categoryPriority(right)); + const senderLabel = resolveUserLabel({ + pubkey: item.pubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + }); + const subject = feedHeadline(item); + const preview = feedPreview(item); + const mentionNames = resolveMentionNames(item.tags, profiles) ?? []; + const channelLabel = item.channelName.trim() || null; + const categoryLabel = categoryLabelFor(categories[0] ?? item.category); + + return { + avatarUrl: profiles?.[item.pubkey.toLowerCase()]?.avatarUrl ?? null, + id: item.id, + item, + categories, + categoryLabel, + channelLabel, + fullTimestampLabel: formatInboxFullTimestamp(item.createdAt), + isActionRequired: categories.includes("needs_action"), + latestActivityAt: group.latestActivityAt, + mentionNames, + preview, + searchableText: [ + senderLabel, + subject, + preview, + ...group.items.map(feedPreview), + channelLabel ?? "", + categoryLabel, + ] + .join(" ") + .toLowerCase(), senderLabel, subject, - preview, - channelLabel ?? "", - categoryLabel, - ] - .join(" ") - .toLowerCase(), - senderLabel, - subject, - timestampLabel: formatInboxTimestamp(item.createdAt), - }; - }); + timestampLabel: formatInboxTimestamp(group.latestActivityAt), + }; + }); } diff --git a/desktop/src/features/home/ui/HomeScreen.tsx b/desktop/src/features/home/ui/HomeScreen.tsx index 6f081241f2..21694e18f8 100644 --- a/desktop/src/features/home/ui/HomeScreen.tsx +++ b/desktop/src/features/home/ui/HomeScreen.tsx @@ -1,9 +1,5 @@ -import { RefreshCcw } from "lucide-react"; - -import { ChatHeader } from "@/features/chat/ui/ChatHeader"; import { useHomeFeedQuery } from "@/features/home/hooks"; import { HomeView } from "@/features/home/ui/HomeView"; -import { Button } from "@/shared/ui/button"; type HomeScreenProps = { availableChannelIds: ReadonlySet; @@ -19,45 +15,22 @@ export function HomeScreen({ const homeFeedQuery = useHomeFeedQuery(); return ( - <> - { - void homeFeedQuery.refetch(); - }} - type="button" - variant="outline" - > - - Refresh - +
+ { + void homeFeedQuery.refetch(); + }} /> - -
- { - void homeFeedQuery.refetch(); - }} - /> -
- +
); } diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index aec499b936..f47186a535 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { RefreshCcw } from "lucide-react"; +import { useChannelsQuery } from "@/features/channels/hooks"; import { type InboxFilter, type InboxReply, @@ -10,6 +11,8 @@ import { import { useFeedItemState } from "@/features/home/useFeedItemState"; import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane"; import { InboxListPane } from "@/features/home/ui/InboxListPane"; +import { useChannelMessagesQuery } from "@/features/messages/hooks"; +import { getThreadReference } from "@/features/messages/lib/threading"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { resolveUserLabel } from "@/features/profile/lib/identity"; import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri"; @@ -17,6 +20,14 @@ import type { HomeFeedResponse } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { Skeleton } from "@/shared/ui/skeleton"; +function matchesInboxFilter(item: { categories: InboxFilter[] }, filter: InboxFilter) { + if (filter === "all") { + return item.categories.some((category) => category !== "activity"); + } + + return item.categories.includes(filter); +} + function HomeLoadingState() { return (
@@ -77,18 +88,58 @@ export function HomeView({ >({}); const { doneSet, markDone, undoDone } = useFeedItemState(currentPubkey); const feedItems = React.useMemo( - () => (feed ? [...feed.feed.mentions, ...feed.feed.needsAction] : []), + () => + feed + ? [ + ...feed.feed.mentions, + ...feed.feed.needsAction, + ...feed.feed.activity, + ...feed.feed.agentActivity, + ] + : [], [feed], ); + + const channelsQuery = useChannelsQuery(); + const channels = channelsQuery.data; + const selectedChannelIdCandidate = React.useMemo(() => { + if (!selectedItemId) return null; + const match = feedItems.find((item) => item.id === selectedItemId); + return match?.channelId ?? null; + }, [feedItems, selectedItemId]); + const selectedChannel = React.useMemo(() => { + if (!selectedChannelIdCandidate || !channels) return null; + return ( + channels.find((channel) => channel.id === selectedChannelIdCandidate) ?? + null + ); + }, [channels, selectedChannelIdCandidate]); + + const channelMessagesQuery = useChannelMessagesQuery(selectedChannel); + const channelMessages = channelMessagesQuery.data; + const threadEvents = React.useMemo(() => { + if (!selectedItemId || !channelMessages) return []; + return channelMessages + .filter((event) => { + if (event.id === selectedItemId) return false; + const ref = getThreadReference(event.tags); + return ( + ref.parentId === selectedItemId || ref.rootId === selectedItemId + ); + }) + .sort((a, b) => a.created_at - b.created_at); + }, [channelMessages, selectedItemId]); + const feedProfilePubkeys = React.useMemo( () => [ ...new Set([ ...feedItems.map((item) => item.pubkey), + ...threadEvents.map((event) => event.pubkey), ...(currentPubkey ? [currentPubkey] : []), ]), ], - [currentPubkey, feedItems], + [currentPubkey, feedItems, threadEvents], ); const feedProfilesQuery = useUsersBatchQuery( feedProfilePubkeys, @@ -110,8 +161,7 @@ export function HomeView({ const normalizedQuery = searchValue.trim().toLowerCase(); return inboxItems.filter((item) => { - const matchesFilter = - filter === "all" ? true : item.item.category === filter; + const matchesFilter = matchesInboxFilter(item, filter); const matchesQuery = normalizedQuery.length === 0 || item.searchableText.includes(normalizedQuery); @@ -121,9 +171,32 @@ export function HomeView({ }, [filter, inboxItems, searchValue]); const selectedItem = filteredItems.find((item) => item.id === selectedItemId) ?? null; - const selectedItemReplies = selectedItem - ? localRepliesByItemId[selectedItem.id] ?? [] - : []; + const threadReplies = React.useMemo( + () => + threadEvents.map((event) => ({ + id: event.id, + authorLabel: resolveUserLabel({ + pubkey: event.pubkey, + currentPubkey, + profiles: feedProfiles, + preferResolvedSelfLabel: true, + }), + avatarUrl: + feedProfiles?.[event.pubkey.toLowerCase()]?.avatarUrl ?? null, + content: event.content, + fullTimestampLabel: formatInboxFullTimestamp(event.created_at), + })), + [currentPubkey, feedProfiles, threadEvents], + ); + const selectedItemReplies = React.useMemo(() => { + if (!selectedItem) return []; + const localReplies = localRepliesByItemId[selectedItem.id] ?? []; + const remoteIds = new Set(threadReplies.map((reply) => reply.id)); + const pendingLocals = localReplies.filter( + (reply) => !remoteIds.has(reply.id), + ); + return [...threadReplies, ...pendingLocals]; + }, [localRepliesByItemId, selectedItem, threadReplies]); React.useEffect(() => { if (filteredItems.length === 0) { setSelectedItemId(null); @@ -136,6 +209,7 @@ export function HomeView({ }, [filteredItems, selectedItemId]); React.useEffect(() => { + void selectedItemId; setIsDeletingMessage(false); setIsSendingReply(false); }, [selectedItemId]); @@ -208,7 +282,10 @@ export function HomeView({ items={filteredItems} onFilterChange={setFilter} onSearchChange={setSearchValue} - onSelect={setSelectedItemId} + onSelect={(itemId) => { + setSelectedItemId(itemId); + markDone(itemId); + }} searchValue={searchValue} selectedId={selectedItemId} /> @@ -225,12 +302,7 @@ export function HomeView({ isDeletingMessage={isDeletingMessage} isSendingReply={isSendingReply} item={selectedItem} - localReplies={selectedItemReplies} - onArchive={() => { - if (selectedItem) { - handleToggleDone(selectedItem.id); - } - }} + replies={selectedItemReplies} onDelete={() => { if (!selectedItem || !canDelete) { return; diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index a808c63b30..fe42dd6bda 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -1,14 +1,11 @@ import { ArrowUpRight, - Archive, CheckCheck, CircleDot, Mail, MailOpen, - Forward, MoreHorizontal, Reply, - ReplyAll, Trash2, } from "lucide-react"; import * as React from "react"; @@ -22,7 +19,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { @@ -42,8 +38,7 @@ type InboxDetailPaneProps = { isDeletingMessage?: boolean; isSendingReply?: boolean; item: InboxItem | null; - localReplies?: InboxReply[]; - onArchive: () => void; + replies?: InboxReply[]; onDelete: () => void; onOpenChannel: (channelId: string) => void; onSendReply: ( @@ -63,8 +58,7 @@ export function InboxDetailPane({ isDeletingMessage = false, isSendingReply = false, item, - localReplies = [], - onArchive, + replies = [], onDelete, onOpenChannel, onSendReply, @@ -152,22 +146,15 @@ export function InboxDetailPane({
-
- } - /> - } - /> - } - /> -
+ {canReply ? ( +
+ } + /> +
+ ) : null}
{canOpenChannel && channelId ? (
- + {canDelete ? ( + + ) : null}
- -
-

{item.subject}

-
@@ -214,10 +197,18 @@ export function InboxDetailPane({ tight />
- {localReplies.map((reply) => ( + {replies.length > 0 ? (
+ {replies.length === 1 ? "1 reply" : `${replies.length} replies`} +
+ ) : null} + {replies.map((reply) => ( +
@@ -249,12 +240,14 @@ export function InboxDetailPane({ @@ -293,14 +286,10 @@ function HeaderIconAction({ } function HeaderMoreMenu({ - canDelete, isDeletingMessage, - onArchive, onDelete, }: { - canDelete: boolean; isDeletingMessage: boolean; - onArchive: () => void; onDelete: () => void; }) { const trigger = ( @@ -324,14 +313,9 @@ function HeaderMoreMenu({ More actions - - - Archive for now - - diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 85fa1f6dfe..7bc5a7cad9 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,4 +1,4 @@ -import { Search } from "lucide-react"; +import { Inbox, Search } from "lucide-react"; import type { InboxFilter, InboxItem } from "@/features/home/lib/inbox"; import { groupInboxItems } from "@/features/home/lib/inbox"; @@ -11,6 +11,8 @@ const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [ { value: "all", label: "All" }, { value: "mention", label: "Mentions" }, { value: "needs_action", label: "Needs Action" }, + { value: "activity", label: "Activity" }, + { value: "agent_activity", label: "Agents" }, ]; type InboxListPaneProps = { @@ -38,7 +40,19 @@ export function InboxListPane({ return (
-
+
+
+ +

+ Inbox +

+
-

+

{item.senderLabel}

{item.isActionRequired ? ( @@ -134,18 +152,35 @@ export function InboxListPane({ ) : null}
- + {item.timestampLabel}
-

+

{item.preview}

{item.channelLabel ? ( - + #{item.channelLabel} ) : null} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 06bad1f233..0ae44dc236 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -17,6 +17,7 @@ import { } from "@/features/messages/lib/normalizeMentionClipboard"; import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor"; import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { ChannelAutocomplete } from "./ChannelAutocomplete"; import { ComposerAttachments } from "./ComposerAttachments"; @@ -29,6 +30,7 @@ import { MessageComposerToolbar } from "./MessageComposerToolbar"; type MessageComposerProps = { channelId?: string | null; channelName: string; + containerClassName?: string; disabled?: boolean; draftKey?: string; editTarget?: { @@ -58,6 +60,7 @@ type MessageComposerProps = { export function MessageComposer({ channelId = null, channelName, + containerClassName, disabled = false, draftKey, editTarget = null, @@ -87,6 +90,8 @@ export function MessageComposer({ const drafts = useDrafts(); const effectiveDraftKey = draftKey ?? channelId; const previousDraftKeyRef = React.useRef(null); + const effectiveDraftKeyRef = React.useRef(effectiveDraftKey); + effectiveDraftKeyRef.current = effectiveDraftKey; const mentions = useMentions(channelId); const channelLinks = useChannelLinks(); @@ -107,13 +112,11 @@ export function MessageComposer({ const onSendRef = React.useRef(onSend); const onEditSaveRef = React.useRef(onEditSave); const editTargetRef = React.useRef(editTarget); - const channelIdRef = React.useRef(channelId); disabledRef.current = disabled; isSendingRef.current = isSending; onSendRef.current = onSend; onEditSaveRef.current = onEditSave; editTargetRef.current = editTarget; - channelIdRef.current = channelId; // ── Refs consumed by Tiptap's submitOnEnter extension ────────────── const isAutocompleteOpenRef = React.useRef(false); @@ -352,11 +355,11 @@ export function MessageComposer({ channelLinks.clearChannels(); setIsEmojiPickerOpen(false); - const sendChannelId = channelIdRef.current; + const sentDraftKey = effectiveDraftKeyRef.current; try { await onSendRef.current(finalContent, pubkeys, mediaTags); - if (sendChannelId) { - drafts.clearDraft(sendChannelId); + if (sentDraftKey) { + drafts.clearDraft(sentDraftKey); } } catch { setContent(savedContent); @@ -498,7 +501,12 @@ export function MessageComposer({ // ── Render ────────────────────────────────────────────────────────── return ( -
) : null}