diff --git a/desktop/src/app/routes/index.tsx b/desktop/src/app/routes/index.tsx index 2a111bc88..21942ba15 100644 --- a/desktop/src/app/routes/index.tsx +++ b/desktop/src/app/routes/index.tsx @@ -1,57 +1,14 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; -import type { FeedItem, SearchHit } from "@/shared/api/types"; import { useIdentityQuery } from "@/shared/api/hooks"; import { HomeScreen } from "@/features/home/ui/HomeScreen"; -import { - KIND_FORUM_COMMENT, - KIND_FORUM_POST, - KIND_JOB_ACCEPTED, - KIND_JOB_CANCEL, - KIND_JOB_ERROR, - KIND_JOB_PROGRESS, - KIND_JOB_REQUEST, - KIND_JOB_RESULT, - KIND_STREAM_MESSAGE, - KIND_STREAM_MESSAGE_V2, -} from "@/shared/constants/kinds"; - -function canOpenFeedItemAsExactEvent(item: FeedItem) { - return ( - item.kind === KIND_STREAM_MESSAGE || - item.kind === KIND_STREAM_MESSAGE_V2 || - item.kind === KIND_JOB_REQUEST || - item.kind === KIND_JOB_ACCEPTED || - item.kind === KIND_JOB_PROGRESS || - item.kind === KIND_JOB_RESULT || - item.kind === KIND_JOB_CANCEL || - item.kind === KIND_JOB_ERROR || - item.kind === KIND_FORUM_POST || - item.kind === KIND_FORUM_COMMENT - ); -} - -function toFeedItemSearchHit(item: FeedItem): SearchHit { - return { - channelId: item.channelId, - channelName: item.channelName || null, - content: item.content, - createdAt: item.createdAt, - eventId: item.id, - kind: item.kind, - pubkey: item.pubkey, - score: 0, - }; -} export const Route = createFileRoute("/")({ component: HomeRouteComponent, }); function HomeRouteComponent() { - const { goChannel, goPulse, openSearchHit } = useAppNavigation(); const channelsQuery = useChannelsQuery(); const identityQuery = useIdentityQuery(); const channels = channelsQuery.data ?? []; @@ -61,21 +18,6 @@ function HomeRouteComponent() { { - if (!item.channelId) { - return; - } - - if (canOpenFeedItemAsExactEvent(item)) { - void openSearchHit(toFeedItemSearchHit(item)); - return; - } - - void goChannel(item.channelId); - }} - onOpenPulse={() => { - void goPulse(); - }} /> ); } diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 20b070ce6..a3ebf50d5 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -5,7 +5,7 @@ import { FileText, FolderGit2, Hash, - Home, + House, Lock, Zap, } from "lucide-react"; @@ -19,7 +19,7 @@ import { useSidebar } from "@/shared/ui/sidebar"; type ChatHeaderProps = { actions?: React.ReactNode; title: string; - description: string; + description?: string; channelType?: ChannelType; visibility?: ChannelVisibility; mode?: "home" | "channel" | "agents" | "workflows" | "pulse" | "projects"; @@ -39,7 +39,7 @@ function ChannelIcon({ mode?: "home" | "channel" | "agents" | "workflows" | "pulse" | "projects"; }) { if (mode === "home") { - return ; + return ; } if (mode === "agents") { @@ -83,7 +83,7 @@ export function ChatHeader({ overlaysContent = false, statusBadge, }: ChatHeaderProps) { - const trimmedDescription = description.trim(); + const trimmedDescription = description?.trim() ?? ""; const { state: sidebarState } = useSidebar(); const reserveGlobalControls = sidebarState === "collapsed"; diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts new file mode 100644 index 000000000..0d6e7a99a --- /dev/null +++ b/desktop/src/features/home/lib/inbox.ts @@ -0,0 +1,340 @@ +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { getThreadReference } from "@/features/messages/lib/threading"; +import type { TimelineReaction } from "@/features/messages/types"; +import type { + FeedItem, + FeedItemCategory, + HomeFeedResponse, + RelayEvent, +} from "@/shared/api/types"; +import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; + +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; + senderLabel: string; + subject: string; + timestampLabel: string; +}; + +export type InboxReply = { + authorLabel: string; + avatarUrl: string | null; + content: string; + depth?: number; + fullTimestampLabel: string; + id: string; + parentId?: string | null; + reactions?: TimelineReaction[]; + rootId?: string | null; +}; + +export type InboxContextMessage = InboxReply & { + depth: number; + isSelected: boolean; + mentionNames: string[]; +}; + +export type InboxGroup = { + label: string; + items: InboxItem[]; +}; + +const listTimeFormatter = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", +}); + +const fullTimeFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", +}); + +const shortDateFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", +}); + +const shortDateWithYearFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", +}); + +const weekdayFormatter = new Intl.DateTimeFormat("en-US", { + weekday: "long", +}); + +function startOfDay(value: Date) { + return new Date(value.getFullYear(), value.getMonth(), value.getDate()); +} + +function diffInDays(from: Date, to: Date) { + return Math.round( + (startOfDay(from).getTime() - startOfDay(to).getTime()) / 86_400_000, + ); +} + +function feedHeadline(item: FeedItem) { + switch (item.kind) { + case 40007: + return "Reminder"; + case 43001: + return "Job requested"; + case 43002: + return "Job accepted"; + case 43003: + return "Progress update"; + case 43004: + return "Job result"; + case 43005: + return "Job cancelled"; + case 43006: + return "Job failed"; + case 45001: + return "Forum post"; + case 45003: + return "Forum reply"; + case 46010: + return "Approval requested"; + default: + if (item.category === "mention") { + return "Mention"; + } + + if (item.category === "agent_activity") { + return "Agent update"; + } + + return "Channel update"; + } +} + +function feedPreview(item: FeedItem) { + const content = item.content.trim(); + if (content.length > 0) { + return content; + } + + if (item.kind === 46010) { + return "A workflow is waiting for approval."; + } + + if (item.kind === 40007) { + return "A reminder is waiting for you."; + } + + 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(); + const dayDiff = diffInDays(now, date); + + if (dayDiff === 0) { + return listTimeFormatter.format(date); + } + + if (dayDiff === 1) { + return "Yesterday"; + } + + if (now.getFullYear() === date.getFullYear()) { + return shortDateFormatter.format(date); + } + + return shortDateWithYearFormatter.format(date); +} + +export function formatInboxFullTimestamp(unixSeconds: number) { + return fullTimeFormatter.format(new Date(unixSeconds * 1_000)); +} + +export function relayEventFromFeedItem(item: FeedItem): RelayEvent { + return { + content: item.content, + created_at: item.createdAt, + id: item.id, + kind: item.kind, + pubkey: item.pubkey, + sig: "", + tags: item.tags, + }; +} + +export function groupInboxItems(items: InboxItem[]): InboxGroup[] { + const groups = new Map(); + const now = new Date(); + + for (const item of items) { + const date = new Date(item.latestActivityAt * 1_000); + const dayDiff = diffInDays(now, date); + const label = + dayDiff === 0 + ? "Today" + : dayDiff === 1 + ? "Yesterday" + : dayDiff < 7 + ? weekdayFormatter.format(date) + : shortDateWithYearFormatter.format(date); + + const current = groups.get(label) ?? []; + current.push(item); + groups.set(label, current); + } + + return [...groups.entries()].map(([label, groupedItems]) => ({ + label, + items: groupedItems, + })); +} + +export function buildInboxItems({ + currentPubkey, + feed, + profiles, +}: { + currentPubkey?: string; + feed?: HomeFeedResponse; + profiles?: UserProfileLookup; +}): InboxItem[] { + if (!feed) { + return []; + } + + const feedItems = [ + ...feed.feed.mentions.map((item) => ({ + ...item, + category: "mention" as const, + })), + ...feed.feed.needsAction.map((item) => ({ + ...item, + category: "needs_action" as const, + })), + ...feed.feed.activity.map((item) => ({ + ...item, + category: "activity" as const, + })), + ...feed.feed.agentActivity.map((item) => ({ + ...item, + category: "agent_activity" as const, + })), + ]; + + const threadGroups = new Map< + string, + { + items: FeedItem[]; + latestActivityAt: number; + rootItem: FeedItem | null; + } + >(); + + 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, + senderLabel, + subject, + timestampLabel: formatInboxTimestamp(group.latestActivityAt), + }; + }); +} diff --git a/desktop/src/features/home/ui/HomeScreen.tsx b/desktop/src/features/home/ui/HomeScreen.tsx index e44cc07bf..b9d5f22cf 100644 --- a/desktop/src/features/home/ui/HomeScreen.tsx +++ b/desktop/src/features/home/ui/HomeScreen.tsx @@ -1,4 +1,3 @@ -import type { FeedItem } from "@/shared/api/types"; import { ChatHeader } from "@/features/chat/ui/ChatHeader"; import { useHomeFeedQuery } from "@/features/home/hooks"; import { HomeView } from "@/features/home/ui/HomeView"; @@ -6,15 +5,11 @@ import { HomeView } from "@/features/home/ui/HomeView"; type HomeScreenProps = { availableChannelIds: ReadonlySet; currentPubkey?: string; - onOpenFeedItem: (item: FeedItem) => void; - onOpenPulse: () => void; }; export function HomeScreen({ availableChannelIds, currentPubkey, - onOpenFeedItem, - onOpenPulse, }: HomeScreenProps) { const homeFeedQuery = useHomeFeedQuery(); @@ -38,8 +33,6 @@ export function HomeScreen({ } feed={homeFeedQuery.data} isLoading={homeFeedQuery.isLoading} - onOpenFeedItem={onOpenFeedItem} - onOpenPulse={onOpenPulse} onRefresh={() => { void homeFeedQuery.refetch(); }} diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 8223f26dc..cadb6f2bf 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -1,60 +1,102 @@ import * as React from "react"; -import { Activity, AtSign, Bot, CircleAlert, RefreshCcw } from "lucide-react"; +import { RefreshCcw } from "lucide-react"; -import { useRelayAgentsQuery } from "@/features/agents/hooks"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { + type InboxFilter, + type InboxContextMessage, + type InboxReply, + buildInboxItems, + formatInboxFullTimestamp, +} from "@/features/home/lib/inbox"; import { useFeedItemState } from "@/features/home/useFeedItemState"; +import { useInboxThreadContext } from "@/features/home/useInboxThreadContext"; +import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane"; +import { InboxListPane } from "@/features/home/ui/InboxListPane"; +import { + useChannelMessagesQuery, + useToggleReactionMutation, +} from "@/features/messages/hooks"; +import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; +import { getThreadReference } from "@/features/messages/lib/threading"; import { useUsersBatchQuery } from "@/features/profile/hooks"; -import { useContactListQuery, useTimelineQuery } from "@/features/pulse/hooks"; -import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; -import type { FeedItem, HomeFeedResponse } from "@/shared/api/types"; +import { resolveUserLabel } from "@/features/profile/lib/identity"; +import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri"; +import type { HomeFeedResponse, RelayEvent } from "@/shared/api/types"; +import { KIND_REACTION } from "@/shared/constants/kinds"; +import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; import { Button } from "@/shared/ui/button"; import { Skeleton } from "@/shared/ui/skeleton"; -const FeedSection = React.lazy(async () => { - const module = await import("./FeedSection"); - return { default: module.FeedSection }; -}); - -const RecentNotesSection = React.lazy(async () => { - const module = await import("./RecentNotesSection"); - return { default: module.RecentNotesSection }; -}); +function matchesInboxFilter( + item: { categories: InboxFilter[] }, + filter: InboxFilter, +) { + if (filter === "all") { + return item.categories.some((category) => category !== "activity"); + } -type FeedFilter = - | "all" - | "mention" - | "needs_action" - | "activity" - | "agent_activity"; + return item.categories.includes(filter); +} function HomeLoadingState() { return ( -
-
-
- {["mentions", "actions"].map((section) => ( -
- -
- {["a", "b", "c"].map((row) => ( - - ))} -
-
- ))} +
+
+
+
+ + + +
+
+ {["a", "b", "c", "d"].map((row) => ( + + ))} +
+
+ +
+
+ + +
+
+ +
); } -const FILTER_OPTIONS: { value: FeedFilter; label: string }[] = [ - { value: "all", label: "All" }, - { value: "mention", label: "Mentions" }, - { value: "needs_action", label: "Needs Action" }, - { value: "activity", label: "Activity" }, - { value: "agent_activity", label: "Agent Updates" }, -]; +function getContextMessageDepth( + event: RelayEvent, + eventById: ReadonlyMap, +): number { + let depth = 0; + let parentId = getThreadReference(event.tags).parentId; + const seen = new Set([event.id]); + + while (parentId && eventById.has(parentId) && !seen.has(parentId)) { + depth += 1; + seen.add(parentId); + parentId = getThreadReference(eventById.get(parentId)?.tags ?? []).parentId; + } + + return depth; +} + +function getReactionTargetId(tags: string[][]) { + for (let index = tags.length - 1; index >= 0; index -= 1) { + const tag = tags[index]; + if (tag?.[0] === "e" && typeof tag[1] === "string") { + return tag[1]; + } + } + + return null; +} type HomeViewProps = { feed?: HomeFeedResponse; @@ -62,8 +104,6 @@ type HomeViewProps = { errorMessage?: string; currentPubkey?: string; availableChannelIds: ReadonlySet; - onOpenFeedItem: (item: FeedItem) => void; - onOpenPulse: () => void; onRefresh: () => void; }; @@ -73,64 +113,170 @@ export function HomeView({ errorMessage, currentPubkey, availableChannelIds, - onOpenFeedItem, - onOpenPulse, onRefresh, }: HomeViewProps) { - const [filter, setFilter] = React.useState("all"); - const { doneSet, markDone, undoDone } = useFeedItemState(currentPubkey); - - // Defer Pulse widget queries until the shell is interactive - const startupReady = useDeferredStartup(); - const deferredPubkey = startupReady ? currentPubkey : undefined; - - // Recent notes for the Pulse widget - const contactListQuery = useContactListQuery(deferredPubkey); - const contactPubkeys = React.useMemo( - () => (contactListQuery.data?.contacts ?? []).map((c) => c.pubkey), - [contactListQuery.data], + const [filter, setFilter] = React.useState("all"); + const [selectedItemId, setSelectedItemId] = React.useState( + null, ); - const notesPubkeys = React.useMemo( + const [isDeletingMessage, setIsDeletingMessage] = React.useState(false); + const [isSendingReply, setIsSendingReply] = React.useState(false); + const [localRepliesByItemId, setLocalRepliesByItemId] = React.useState< + Record + >({}); + const { doneSet, markDone, undoDone } = useFeedItemState(currentPubkey); + const feedItems = React.useMemo( () => - deferredPubkey - ? [...new Set([deferredPubkey, ...contactPubkeys])] - : contactPubkeys, - [deferredPubkey, contactPubkeys], + feed + ? [ + ...feed.feed.mentions, + ...feed.feed.needsAction, + ...feed.feed.activity, + ...feed.feed.agentActivity, + ] + : [], + [feed], ); - const notesTimelineQuery = useTimelineQuery( - notesPubkeys, - notesPubkeys.length > 0, + const selectedFeedItem = + feedItems.find((item) => item.id === selectedItemId) ?? null; + + const channelsQuery = useChannelsQuery(); + const channels = channelsQuery.data; + const selectedChannelIdCandidate = React.useMemo(() => { + return selectedFeedItem?.channelId ?? null; + }, [selectedFeedItem]); + const selectedChannel = React.useMemo(() => { + if (!selectedChannelIdCandidate || !channels) return null; + return ( + channels.find((channel) => channel.id === selectedChannelIdCandidate) ?? + null + ); + }, [channels, selectedChannelIdCandidate]); + + const channelMessagesQuery = useChannelMessagesQuery(selectedChannel); + const toggleReactionMutation = useToggleReactionMutation(); + const channelMessages = channelMessagesQuery.data; + const threadContext = useInboxThreadContext( + selectedFeedItem, + channelMessages, ); - const recentNotes = notesTimelineQuery.data?.notes?.slice(0, 5) ?? []; - const noteAuthorPubkeys = React.useMemo( - () => [...new Set(recentNotes.map((n) => n.pubkey))], - [recentNotes], + + const feedProfilePubkeys = React.useMemo( + () => [ + ...new Set([ + ...feedItems.map((item) => item.pubkey), + ...threadContext.events.map((event) => event.pubkey), + ...(channelMessages ?? []) + .filter((event) => event.kind === KIND_REACTION) + .map((event) => event.pubkey), + ...(currentPubkey ? [currentPubkey] : []), + ]), + ], + [channelMessages, currentPubkey, feedItems, threadContext.events], ); - const noteProfilesQuery = useUsersBatchQuery(noteAuthorPubkeys, { - enabled: noteAuthorPubkeys.length > 0, + const feedProfilesQuery = useUsersBatchQuery(feedProfilePubkeys, { + enabled: feedProfilePubkeys.length > 0, }); - const noteProfiles = noteProfilesQuery.data?.profiles ?? {}; - const relayAgentsQuery = useRelayAgentsQuery({ enabled: startupReady }); - const agentPubkeySet = React.useMemo( - () => new Set((relayAgentsQuery.data ?? []).map((a) => a.pubkey)), - [relayAgentsQuery.data], + const feedProfiles = feedProfilesQuery.data?.profiles; + const inboxItems = React.useMemo( + () => + buildInboxItems({ + currentPubkey, + feed, + profiles: feedProfiles, + }), + [currentPubkey, feed, feedProfiles], ); + const filteredItems = React.useMemo(() => { + return inboxItems.filter((item) => matchesInboxFilter(item, filter)); + }, [filter, inboxItems]); + const selectedItem = + filteredItems.find((item) => item.id === selectedItemId) ?? null; + const contextMessages = React.useMemo(() => { + if (!selectedItem) { + return []; + } + + const eventById = new Map( + threadContext.events.map((event) => [event.id, event]), + ); + const contextEventIds = new Set(eventById.keys()); + const reactionEvents = (channelMessages ?? []).filter((event) => { + if (event.kind !== KIND_REACTION) { + return false; + } + + const targetId = getReactionTargetId(event.tags); + return Boolean(targetId && contextEventIds.has(targetId)); + }); + const currentUserAvatarUrl = currentPubkey + ? (feedProfiles?.[currentPubkey.toLowerCase()]?.avatarUrl ?? null) + : null; + const timelineMessages = formatTimelineMessages( + [...threadContext.events, ...reactionEvents], + selectedChannel, + currentPubkey, + currentUserAvatarUrl, + feedProfiles, + ); - const feedItems = feed - ? [ - ...feed.feed.mentions, - ...feed.feed.needsAction, - ...(feed.feed.activity ?? []), - ...(feed.feed.agentActivity ?? []), - ] - : []; - const feedProfilesQuery = useUsersBatchQuery( - feedItems.map((item) => item.pubkey), - { - enabled: feedItems.length > 0, + return timelineMessages.map((message) => { + const event = eventById.get(message.id); + return { + id: message.id, + authorLabel: message.author, + avatarUrl: message.avatarUrl ?? null, + content: message.body, + depth: event ? getContextMessageDepth(event, eventById) : message.depth, + fullTimestampLabel: formatInboxFullTimestamp(message.createdAt), + isSelected: message.id === selectedItem.id, + mentionNames: + resolveMentionNames(message.tags ?? [], feedProfiles) ?? [], + reactions: message.reactions, + }; + }); + }, [ + channelMessages, + currentPubkey, + feedProfiles, + selectedChannel, + selectedItem, + threadContext.events, + ]); + const selectedItemReplies = React.useMemo(() => { + if (!selectedItem) return []; + const localReplies = localRepliesByItemId[selectedItem.id] ?? []; + const contextIds = new Set(contextMessages.map((message) => message.id)); + return localReplies.filter((reply) => !contextIds.has(reply.id)); + }, [contextMessages, localRepliesByItemId, selectedItem]); + React.useEffect(() => { + if (filteredItems.length === 0) { + setSelectedItemId(null); + return; + } + + if (!filteredItems.some((item) => item.id === selectedItemId)) { + setSelectedItemId(filteredItems[0]?.id ?? null); + } + }, [filteredItems, selectedItemId]); + + React.useEffect(() => { + void selectedItemId; + setIsDeletingMessage(false); + setIsSendingReply(false); + }, [selectedItemId]); + + const handleToggleDone = React.useCallback( + (itemId: string) => { + if (doneSet.has(itemId)) { + undoDone(itemId); + return; + } + + markDone(itemId); }, + [doneSet, markDone, undoDone], ); - const feedProfiles = feedProfilesQuery.data?.profiles; if (isLoading && !feed) { return ; @@ -138,8 +284,8 @@ export function HomeView({ if (!feed) { return ( -
-
+
+

Home feed unavailable @@ -157,111 +303,142 @@ export function HomeView({ ); } - const showMentions = filter === "all" || filter === "mention"; - const showNeedsAction = filter === "all" || filter === "needs_action"; - const showActivity = filter === "all" || filter === "activity"; - const showAgentActivity = filter === "all" || filter === "agent_activity"; + const canReply = + selectedItem !== null && + selectedItem.item.channelId !== null && + availableChannelIds.has(selectedItem.item.channelId) && + selectedItem.item.kind !== 45001 && + selectedItem.item.kind !== 45003; + const disabledReplyReason = + canReply || !selectedItem + ? null + : selectedItem.item.channelId + ? availableChannelIds.has(selectedItem.item.channelId) + ? "This item does not support inline replies yet." + : "Open the linked channel to reply." + : "This inbox item does not have a reply target."; + const canDelete = + selectedItem !== null && + currentPubkey?.trim().toLowerCase() === + selectedItem.item.pubkey.trim().toLowerCase(); + return ( -

-
-
- {FILTER_OPTIONS.map((option) => ( - - ))} -
+
+
+ { + setSelectedItemId(itemId); + markDone(itemId); + }} + selectedId={selectedItemId} + /> - {recentNotes.length > 0 ? ( - - - - ) : null} + { + if (!selectedItem || !canDelete) { + return; + } - -
- {showMentions ? ( - - ) : null} - {showNeedsAction ? ( - - ) : null} - {showActivity ? ( - - ) : null} - {showAgentActivity ? ( - - ) : null} -
-
+ setIsDeletingMessage(true); + void deleteMessage(selectedItem.id) + .then(() => { + onRefresh(); + }) + .finally(() => { + setIsDeletingMessage(false); + }); + }} + onSendReply={async ({ + content, + mediaTags, + mentionPubkeys, + parentEventId, + }) => { + const channelId = selectedItem?.item.channelId; + if (!selectedItem || !channelId || !canReply) { + throw new Error("Replies are not available for this item."); + } + + const itemToReply = selectedItem; + setIsSendingReply(true); + try { + const result = await sendChannelMessage( + channelId, + content, + parentEventId, + mediaTags, + mentionPubkeys, + ); + const authorPubkey = currentPubkey ?? itemToReply.item.pubkey; + const reply: InboxReply = { + authorLabel: currentPubkey + ? resolveUserLabel({ + currentPubkey, + profiles: feedProfiles, + pubkey: authorPubkey, + }) + : "You", + avatarUrl: + currentPubkey && feedProfiles + ? (feedProfiles[currentPubkey.trim().toLowerCase()] + ?.avatarUrl ?? null) + : null, + content, + depth: result.depth, + fullTimestampLabel: formatInboxFullTimestamp(result.createdAt), + id: result.eventId, + parentId: result.parentEventId, + rootId: result.rootEventId, + }; + setLocalRepliesByItemId((current) => ({ + ...current, + [itemToReply.id]: [...(current[itemToReply.id] ?? []), reply], + })); + onRefresh(); + } finally { + setIsSendingReply(false); + } + }} + onToggleDone={() => { + if (selectedItem) { + handleToggleDone(selectedItem.id); + } + }} + onToggleReaction={ + canReply + ? async (message, emoji, remove) => { + await toggleReactionMutation.mutateAsync({ + emoji, + eventId: message.id, + remove, + }); + await channelMessagesQuery.refetch(); + onRefresh(); + } + : undefined + } + />
); diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx new file mode 100644 index 000000000..ca76f7116 --- /dev/null +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -0,0 +1,470 @@ +import { + CheckCheck, + Mail, + MailOpen, + MoreHorizontal, + Trash2, +} from "lucide-react"; +import * as React from "react"; + +import type { + InboxContextMessage, + InboxItem, + InboxReply, +} from "@/features/home/lib/inbox"; +import type { TimelineMessage } from "@/features/messages/types"; +import { MessageActionBar } from "@/features/messages/ui/MessageActionBar"; +import { MessageComposer } from "@/features/messages/ui/MessageComposer"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Markdown } from "@/shared/ui/markdown"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/shared/ui/tooltip"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +type InboxDetailPaneProps = { + canDelete: boolean; + canOpenChannel: boolean; + canReply: boolean; + disabledReplyReason?: string | null; + isDone: boolean; + isDeletingMessage?: boolean; + isSendingReply?: boolean; + isThreadContextLoading?: boolean; + item: InboxItem | null; + messages?: InboxContextMessage[]; + replies?: InboxReply[]; + onDelete: () => void; + onSendReply: (input: { + content: string; + mediaTags?: string[][]; + mentionPubkeys: string[]; + parentEventId: string; + }) => Promise; + onToggleDone: () => void; + onToggleReaction?: ( + message: TimelineMessage, + emoji: string, + remove: boolean, + ) => Promise; +}; + +type InboxDisplayMessage = InboxContextMessage & { + depth: number; +}; + +function toActionBarMessage(message: InboxDisplayMessage): TimelineMessage { + return { + id: message.id, + author: message.authorLabel, + avatarUrl: message.avatarUrl, + body: message.content, + createdAt: 0, + depth: message.depth, + reactions: message.reactions ?? [], + time: message.fullTimestampLabel, + }; +} + +export function InboxDetailPane({ + canDelete, + canOpenChannel, + canReply, + disabledReplyReason, + isDone, + isDeletingMessage = false, + isSendingReply = false, + isThreadContextLoading = false, + item, + messages = [], + replies = [], + onDelete, + onSendReply, + onToggleDone, + onToggleReaction, +}: InboxDetailPaneProps) { + const detailPaneRef = React.useRef(null); + const [replyTargetId, setReplyTargetId] = React.useState(null); + const [isFocusHighlightVisible, setIsFocusHighlightVisible] = + React.useState(true); + const selectedItemId = item?.id ?? null; + + const focusComposer = React.useCallback(() => { + window.requestAnimationFrame(() => { + const textarea = + detailPaneRef.current?.querySelector( + '[data-testid="message-input"]', + ); + textarea?.focus(); + }); + }, []); + + React.useEffect(() => { + void selectedItemId; + setReplyTargetId(null); + }, [selectedItemId]); + + React.useEffect(() => { + void selectedItemId; + setIsFocusHighlightVisible(true); + const timeoutId = window.setTimeout(() => { + setIsFocusHighlightVisible(false); + }, 1_200); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [selectedItemId]); + + if (!item) { + return ( +
+
+
+ +
+

Select a message

+

+ Pick an inbox item to see the full message and react to it. +

+
+
+ ); + } + + const selectedMessage = messages.find((message) => message.isSelected); + const pendingReplyMessages: InboxDisplayMessage[] = replies.map((reply) => ({ + ...reply, + depth: reply.depth ?? (selectedMessage?.depth ?? 0) + 1, + isSelected: false, + mentionNames: [], + })); + const displayMessages: InboxDisplayMessage[] = + messages.length > 0 + ? [...messages, ...pendingReplyMessages] + : [ + { + authorLabel: item.senderLabel, + avatarUrl: item.avatarUrl, + content: item.preview, + depth: 0, + fullTimestampLabel: item.fullTimestampLabel, + id: item.id, + isSelected: true, + mentionNames: item.mentionNames, + }, + ...pendingReplyMessages, + ]; + const replyTarget = + displayMessages.find((message) => message.id === replyTargetId) ?? null; + const composerParentEventId = replyTarget?.id ?? item.id; + const composerReplyTarget = + replyTarget && replyTarget.id !== item.id + ? { + author: replyTarget.authorLabel, + body: replyTarget.content, + id: replyTarget.id, + } + : null; + + const handleSelectReplyTarget = (message: InboxDisplayMessage) => { + setReplyTargetId((currentReplyTargetId) => + currentReplyTargetId === message.id ? null : message.id, + ); + focusComposer(); + }; + + return ( +
+ {!canOpenChannel ? ( +
+
+
+ +
+
+

+ {item.senderLabel} +

+ + {item.categoryLabel} + + {item.channelLabel ? ( + + #{item.channelLabel} + + ) : null} +
+ +
+ {item.fullTimestampLabel} + Inbox only +
+
+
+ +
+ +
+
+ + ) : ( + + ) + } + /> +
+ {canDelete ? ( + + ) : null} +
+
+
+
+
+ ) : null} + +
+
+
+ {isThreadContextLoading ? ( +
+ Loading context... +
+ ) : null} + {displayMessages.map((message, index) => ( + + {index === 1 ? ( +
+ ) : null} +
+
+ +
+
+

+ {message.authorLabel} +

+ {message.isSelected ? ( + + Inbox item + + ) : null} +

+ {message.fullTimestampLabel} +

+ {canReply || onToggleReaction ? ( +
+
+ { + const actionBarMessage = + toActionBarMessage(message); + const remove = + actionBarMessage.reactions?.some( + (reaction) => + reaction.emoji === emoji && + reaction.reactedByCurrentUser, + ) ?? false; + return onToggleReaction( + actionBarMessage, + emoji, + remove, + ); + } + : undefined + } + onReply={ + canReply + ? () => handleSelectReplyTarget(message) + : undefined + } + reactions={message.reactions ?? []} + /> +
+
+ ) : null} +
+
+ +
+
+
+
+ + ))} +
+
+ +
+
+ setReplyTargetId(null) : undefined + } + onSend={(content, mentionPubkeys, mediaTags) => + onSendReply({ + content, + mediaTags, + mentionPubkeys, + parentEventId: composerParentEventId, + }) + } + placeholder={ + canReply + ? `Send reply to ${item.channelLabel ? `#${item.channelLabel} thread` : "channel thread"}` + : (disabledReplyReason ?? + "Replies are not available for this item.") + } + replyTarget={composerReplyTarget} + /> +
+
+
+
+ ); +} + +function HeaderIconAction({ + icon, + label, + onClick, +}: { + icon: React.ReactNode; + label: string; + onClick?: () => void; +}) { + const button = ( + + ); + + return ( + + {button} + {label} + + ); +} + +function HeaderMoreMenu({ + isDeletingMessage, + onDelete, +}: { + isDeletingMessage: boolean; + onDelete: () => void; +}) { + const trigger = ( + + ); + + return ( + + + + {trigger} + + More actions + + + + + Delete message + + + + ); +} diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx new file mode 100644 index 000000000..7ffac0395 --- /dev/null +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -0,0 +1,153 @@ +import type { InboxFilter, InboxItem } from "@/features/home/lib/inbox"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +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 = { + doneSet: ReadonlySet; + filter: InboxFilter; + items: InboxItem[]; + onFilterChange: (filter: InboxFilter) => void; + onSelect: (itemId: string) => void; + selectedId: string | null; +}; + +export function InboxListPane({ + doneSet, + filter, + items, + onFilterChange, + onSelect, + selectedId, +}: InboxListPaneProps) { + return ( +
+
+
+ {FILTER_OPTIONS.map((option) => ( + + ))} +
+
+ +
+ {items.length === 0 ? ( +
+
+

+ No messages found +

+

+ Switch back to all mail to see more messages. +

+
+
+ ) : ( +
+ {items.map((item) => { + const isSelected = item.id === selectedId; + const isDone = doneSet.has(item.id); + + return ( + + ); + })} +
+ )} +
+
+ ); +} diff --git a/desktop/src/features/home/useInboxThreadContext.ts b/desktop/src/features/home/useInboxThreadContext.ts new file mode 100644 index 000000000..95766ce4b --- /dev/null +++ b/desktop/src/features/home/useInboxThreadContext.ts @@ -0,0 +1,179 @@ +import * as React from "react"; + +import { relayEventFromFeedItem } from "@/features/home/lib/inbox"; +import { + getChannelIdFromTags, + getThreadReference, +} from "@/features/messages/lib/threading"; +import { relayClient } from "@/shared/api/relayClient"; +import { getEventById } from "@/shared/api/tauri"; +import type { FeedItem, RelayEvent } from "@/shared/api/types"; +import { HOME_MENTION_EVENT_KINDS } from "@/shared/constants/kinds"; + +type InboxThreadContextResult = { + events: RelayEvent[]; + isLoading: boolean; +}; + +const THREAD_CONTEXT_LIMIT = 100; + +function dedupeEvents(events: RelayEvent[]): RelayEvent[] { + const eventsById = new Map(); + for (const event of events) { + eventsById.set(event.id, event); + } + return [...eventsById.values()].sort((a, b) => a.created_at - b.created_at); +} + +function getThreadRootId(event: RelayEvent): string { + const thread = getThreadReference(event.tags); + return thread.rootId ?? thread.parentId ?? event.id; +} + +function isSameChannel(event: RelayEvent, channelId: string | null): boolean { + if (!channelId) { + return true; + } + return getChannelIdFromTags(event.tags) === channelId; +} + +export function useInboxThreadContext( + item: FeedItem | null, + channelMessages: RelayEvent[] | undefined, +): InboxThreadContextResult { + const [fetchedEvents, setFetchedEvents] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(false); + + const selectedEvent = React.useMemo( + () => (item ? relayEventFromFeedItem(item) : null), + [item], + ); + + const selectedThreadRootId = selectedEvent + ? getThreadRootId(selectedEvent) + : null; + const selectedParentId = selectedEvent + ? getThreadReference(selectedEvent.tags).parentId + : null; + const selectedChannelId = item?.channelId ?? null; + + React.useEffect(() => { + let isCancelled = false; + + if (!selectedEvent || !selectedThreadRootId) { + setFetchedEvents([]); + setIsLoading(false); + return () => { + isCancelled = true; + }; + } + + async function loadContext() { + const targetEvent = selectedEvent; + const threadRootId = selectedThreadRootId; + if (!targetEvent || !threadRootId) { + return; + } + + setIsLoading(true); + + try { + const eventIds = new Set([threadRootId]); + if (selectedParentId) { + eventIds.add(selectedParentId); + } + + const ancestorEvents = await Promise.all( + [...eventIds] + .filter((eventId) => eventId !== targetEvent.id) + .map(async (eventId) => { + try { + return await getEventById(eventId); + } catch { + return null; + } + }), + ); + + const descendantEvents = + selectedChannelId && threadRootId + ? await relayClient + .fetchEvents({ + "#e": [threadRootId], + "#h": [selectedChannelId], + kinds: [...HOME_MENTION_EVENT_KINDS], + limit: THREAD_CONTEXT_LIMIT, + }) + .catch(() => []) + : []; + + if (isCancelled) { + return; + } + + setFetchedEvents( + dedupeEvents( + [...ancestorEvents, ...descendantEvents].filter( + (event): event is RelayEvent => + event !== null && isSameChannel(event, selectedChannelId), + ), + ), + ); + } finally { + if (!isCancelled) { + setIsLoading(false); + } + } + } + + void loadContext(); + + return () => { + isCancelled = true; + }; + }, [ + selectedChannelId, + selectedEvent, + selectedParentId, + selectedThreadRootId, + ]); + + const events = React.useMemo(() => { + if (!selectedEvent) { + return []; + } + + const localContext = (channelMessages ?? []).filter((event) => { + if (!isSameChannel(event, selectedChannelId)) { + return false; + } + + if (event.id === selectedEvent.id) { + return true; + } + + const thread = getThreadReference(event.tags); + return ( + event.id === selectedThreadRootId || + event.id === selectedParentId || + thread.rootId === selectedThreadRootId || + thread.parentId === selectedThreadRootId || + thread.parentId === selectedEvent.id + ); + }); + + return dedupeEvents([selectedEvent, ...fetchedEvents, ...localContext]); + }, [ + channelMessages, + fetchedEvents, + selectedChannelId, + selectedEvent, + selectedParentId, + selectedThreadRootId, + ]); + + return { + events, + isLoading, + }; +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index fe2715113..0f870985c 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -34,6 +34,7 @@ import { MessageComposerToolbar } from "./MessageComposerToolbar"; type MessageComposerProps = { channelId?: string | null; channelName: string; + containerClassName?: string; disabled?: boolean; draftKey?: string; editTarget?: { @@ -66,6 +67,7 @@ type MessageComposerProps = { export function MessageComposer({ channelId = null, channelName, + containerClassName, disabled = false, draftKey, editTarget = null, @@ -82,7 +84,6 @@ export function MessageComposer({ typingParentEventId = null, typingRootEventId = null, }: MessageComposerProps) { - // ── Markdown content state (synced from Tiptap on every update) ────── const [content, setContent] = React.useState(""); const contentRef = React.useRef(content); contentRef.current = content; @@ -98,6 +99,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 preEditContentRef = React.useRef(null); const mentions = useMentions(channelId, undefined, profiles); const channelLinks = useChannelLinks(); @@ -108,24 +111,20 @@ export function MessageComposer({ typingRootEventId, ); - // ── Media upload ───────────────────────────────────────────────────── // We pass a custom setter that both updates React state AND inserts // markdown into the Tiptap editor when media upload completes. const media = useMediaUpload(); - // ── Stable refs for callbacks ──────────────────────────────────────── const disabledRef = React.useRef(disabled); const isSendingRef = React.useRef(isSending); 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; const isAutocompleteOpenRef = React.useRef(false); isAutocompleteOpenRef.current = @@ -142,7 +141,6 @@ export function MessageComposer({ ? `Reply to ${replyTarget.author} in #${channelName}` : `Message #${channelName}`)); - // ── Tiptap editor ─────────────────────────────────────────────────── const richText = useRichTextEditor({ placeholder: computedPlaceholder, editable: !disabled, @@ -166,7 +164,6 @@ export function MessageComposer({ }, }); - // ── Channel switching: save/restore drafts ────────────────────────── // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger React.useEffect(() => { const prevKey = previousDraftKeyRef.current; @@ -202,7 +199,6 @@ export function MessageComposer({ }; }, [effectiveDraftKey]); - // ── Edit mode: pre-fill content & restore on cancel ───────────────── // biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger React.useEffect(() => { if (editTarget) { @@ -391,11 +387,11 @@ export function MessageComposer({ emojiAutocomplete.clearEmojis(); 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); @@ -550,8 +546,9 @@ export function MessageComposer({ return (
- + {onCancelReply ? ( + + ) : null}
) : null} diff --git a/desktop/tests/e2e/integration.spec.ts b/desktop/tests/e2e/integration.spec.ts index 61fe9296b..29c9d00ed 100644 --- a/desktop/tests/e2e/integration.spec.ts +++ b/desktop/tests/e2e/integration.spec.ts @@ -360,13 +360,10 @@ test("live forum mentions refetch the home feed without waiting for polling", as await targetPage.getByRole("button", { name: "Home" }).click(); await expect(targetPage.getByTestId("chat-title")).toHaveText("Home"); - await expect( - targetPage.getByRole("heading", { name: "Mentions" }), - ).toBeVisible(); - const mentionsSection = targetPage.locator("section").filter({ - has: targetPage.getByRole("heading", { name: "Mentions" }), - }); - await expect(mentionsSection).toContainText(message); + await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible(); + await expect(targetPage.getByTestId("home-inbox-list")).toContainText( + message, + ); await expect(targetPage.getByTestId("sidebar-home-count")).toHaveCount(0); await expect .poll(() => getLoggedNotificationCount(targetPage), { diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index fe6daaa8d..8a174022c 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -45,7 +45,6 @@ async function openSearchDialogWithShortcut( ) { const searchDialog = page.getByTestId("search-dialog"); const openSearchButton = page.getByTestId("open-search"); - const shortcut = process.platform === "darwin" ? "Meta+K" : "Control+K"; await expect(openSearchButton).toBeVisible(); await expect @@ -54,7 +53,19 @@ async function openSearchDialogWithShortcut( return true; } - await page.keyboard.press(shortcut); + await page.evaluate(() => { + const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + code: "KeyK", + ctrlKey: !isMac, + key: "k", + metaKey: isMac, + }), + ); + }); return searchDialog.isVisible(); }) .toBe(true); @@ -134,53 +145,49 @@ test("create agent supports parallelism and system prompt overrides", async ({ }); test("opens a mocked channel from the home feed", async ({ page }) => { - const mentionsSection = page.locator("section").filter({ - has: page.getByRole("heading", { name: "Mentions" }), - }); + const inboxList = page.getByTestId("home-inbox-list"); await page.goto("/"); await expect(page.getByTestId("chat-title")).toHaveText("Home"); - await expect(page.getByRole("heading", { name: "Mentions" })).toBeVisible(); - await expect( - page.getByText("Please review the release checklist."), - ).toBeVisible(); + await expect(inboxList).toContainText("Please review the release checklist."); - await mentionsSection.getByRole("button", { name: "Open general" }).click(); + await inboxList + .getByText("Please review the release checklist.") + .first() + .click(); + await page.getByRole("button", { name: "Open channel" }).click(); await expect(page).toHaveURL( - /#\/channels\/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50\?messageId=mock-feed-mention$/, + /#\/channels\/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50$/, ); await expect(page.getByTestId("chat-title")).toHaveText("general"); - await expect(page.getByTestId("message-timeline")).toContainText( - "Please review the release checklist.", - ); }); test("home feed shows channel and agent activity sections", async ({ page, }) => { + const inboxList = page.getByTestId("home-inbox-list"); + await page.goto("/"); - await expect( - page.getByRole("heading", { name: "Channel Activity" }), - ).toBeVisible(); - await expect( - page.getByRole("heading", { name: "Agent Updates" }), - ).toBeVisible(); - await expect( - page.getByText("Engineering shipped the desktop build."), - ).toBeVisible(); - await expect( - page.getByText("Agent progress: channel index complete."), - ).toBeVisible(); + await page + .getByTestId("home-inbox") + .getByRole("button", { name: "Activity" }) + .click(); + await expect(inboxList).toContainText( + "Engineering shipped the desktop build.", + ); - await page.getByTestId("home-feed-open-mock-feed-agent").click(); - await expect(page).toHaveURL( - /#\/channels\/94a444a4-c0a3-5966-ab05-530c6ddc2301\?messageId=mock-feed-agent$/, + await page + .getByTestId("home-inbox") + .getByRole("button", { name: "Agents" }) + .click(); + await expect(inboxList).toContainText( + "Agent progress: channel index complete.", ); - await expect(page.getByTestId("chat-title")).toHaveText("agents"); - await expect(page.getByTestId("message-timeline")).toContainText( + await inboxList.getByText("Agent progress: channel index complete.").click(); + await expect(page.getByTestId("home-inbox-detail")).toContainText( "Agent progress: channel index complete.", ); }); @@ -189,62 +196,28 @@ test("opens a mocked forum activity item from the home feed", async ({ page, }) => { await page.goto("/"); - await expect( - page.getByRole("heading", { name: "Channel Activity" }), - ).toBeVisible(); - - await page.evaluate(() => { - const win = window as Window & { - __SPROUT_E2E_PUSH_MOCK_FEED_ITEM__?: (item: { - category: "mention" | "needs_action" | "activity" | "agent_activity"; - channel_id: string | null; - channel_name: string; - content: string; - created_at: number; - id: string; - kind: number; - pubkey: string; - tags: string[][]; - }) => unknown; - }; - - win.__SPROUT_E2E_PUSH_MOCK_FEED_ITEM__?.({ - category: "activity", - channel_id: "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11", - channel_name: "watercooler", - content: "Release checklist: async feedback thread.", - created_at: Math.floor(Date.now() / 1000) + 5, - id: "mock-forum-release-thread", - kind: 45001, - pubkey: - "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f", - tags: [["h", "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11"]], - }); - }); - - await expect( - page.getByTestId("home-feed-open-mock-forum-release-thread"), - ).toBeVisible(); - await page.getByTestId("home-feed-open-mock-forum-release-thread").click(); - await expect(page).toHaveURL( - /#\/channels\/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11\/posts\/mock-forum-release-thread$/, + await page + .getByTestId("home-inbox") + .getByRole("button", { name: "Activity" }) + .click(); + await expect(page.getByTestId("home-inbox-list")).toContainText( + "Engineering shipped the desktop build.", + ); + await page + .getByTestId("home-inbox-list") + .getByText("Engineering shipped the desktop build.") + .click(); + await expect(page.getByTestId("home-inbox-detail")).toContainText( + "Engineering shipped the desktop build.", ); - await expect(page.getByTestId("chat-title")).toHaveText("watercooler"); - await expect( - page.getByText("Release checklist: async feedback thread."), - ).toBeVisible(); }); test("home feed renders resolved author labels", async ({ page }) => { - const mentionsSection = page.locator("section").filter({ - has: page.getByRole("heading", { name: "Mentions" }), - }); - await page.goto("/"); - await expect(mentionsSection).toContainText("alice"); - await expect(mentionsSection).not.toContainText("You"); + await expect(page.getByTestId("home-inbox-list")).toContainText("alice"); + await expect(page.getByTestId("home-inbox-list")).not.toContainText("You"); }); test("opens relay-backed search from the sidebar and loads the exact result", async ({ diff --git a/desktop/tests/e2e/stream.spec.ts b/desktop/tests/e2e/stream.spec.ts index 2b6d2e44a..73aaedff0 100644 --- a/desktop/tests/e2e/stream.spec.ts +++ b/desktop/tests/e2e/stream.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Browser, type Page } from "@playwright/test"; -import { installRelayBridge } from "../helpers/bridge"; +import { installRelayBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { assertRelaySeeded } from "../helpers/seed"; const isCi = Boolean(process.env.CI); @@ -43,7 +43,7 @@ async function ensureTimelineScrollable( const message = `${prefix} seed ${index}`; - await expect(input).toHaveAttribute("contenteditable", "true"); + await expect(input).toBeEnabled(); await input.fill(message); await sendButton.click(); await expectTimelineToContain(receiverPage, message); @@ -76,6 +76,56 @@ async function createAndJoinSharedStream( ); } +async function sendChannelMessage( + page: Page, + { + channelName, + content, + mentionPubkeys, + }: { + channelName: string; + content: string; + mentionPubkeys?: string[]; + }, +) { + await page.evaluate( + async ({ channelName: targetChannelName, content, mentionPubkeys }) => { + const tauriWindow = window as Window & { + __TAURI_INTERNALS__?: { + invoke: ( + command: string, + payload?: Record, + ) => Promise; + }; + }; + + const invoke = tauriWindow.__TAURI_INTERNALS__?.invoke; + if (!invoke) { + throw new Error("Tauri invoke bridge is unavailable."); + } + + const channels = (await invoke("get_channels")) as Array<{ + id: string; + name: string; + }>; + const channel = channels.find(({ name }) => name === targetChannelName); + if (!channel) { + throw new Error(`Channel not found: ${targetChannelName}`); + } + + await invoke("send_channel_message", { + channelId: channel.id, + content, + kind: null, + mediaTags: null, + mentionPubkeys: mentionPubkeys ?? null, + parentEventId: null, + }); + }, + { channelName, content, mentionPubkeys }, + ); +} + async function scrollTimelineAwayFromBottom(page: Page, minDistance = 160) { const timeline = page.getByTestId("message-timeline"); await timeline.hover(); @@ -106,21 +156,74 @@ test("loads channels from the relay", async ({ page }) => { await expect(page.getByTestId("dm-list")).toContainText("alice-tyler"); }); -test("loads the home feed from the relay", async ({ page }) => { - await installRelayBridge(page, "tyler"); - await page.goto("/"); +test("loads the home feed from the relay", async ({ browser }) => { + const message = `Relay home mention ${Date.now()}`; + const targetContext = await browser.newContext(); + const senderContext = await browser.newContext(); + const page = await targetContext.newPage(); + const senderPage = await senderContext.newPage(); + + try { + await installRelayBridge(page, "tyler"); + await installRelayBridge(senderPage, "alice"); + await page.goto("/"); + await senderPage.goto("/"); + + await expect(page.getByTestId("chat-title")).toHaveText("Home"); + await expect(page.getByTestId("home-inbox")).toBeVisible(); + + await sendChannelMessage(senderPage, { + channelName: "general", + content: message, + mentionPubkeys: [TEST_IDENTITIES.tyler.pubkey], + }); + + await expect(page.getByTestId("home-inbox-list")).toContainText(message, { + timeout: relayDeliveryTimeoutMs, + }); + await expect(page.getByTestId("home-inbox-detail")).toBeVisible(); + } finally { + await targetContext.close(); + await senderContext.close(); + } +}); - await expect(page.getByTestId("chat-title")).toHaveText("Home"); - await expect(page.getByRole("heading", { name: "Mentions" })).toBeVisible(); - await expect( - page.getByRole("heading", { name: "Needs Action" }), - ).toBeVisible(); - await expect( - page.getByRole("heading", { name: "Channel Activity" }), - ).toBeVisible(); - await expect( - page.getByRole("heading", { name: "Agent Updates" }), - ).toBeVisible(); +test("shows sent inbox replies immediately in the inbox detail pane", async ({ + browser, +}) => { + const message = `Relay inbox reply target ${Date.now()}`; + const reply = `Inbox reply ${Date.now()}`; + const targetContext = await browser.newContext(); + const senderContext = await browser.newContext(); + const page = await targetContext.newPage(); + const senderPage = await senderContext.newPage(); + + try { + await installRelayBridge(page, "tyler"); + await installRelayBridge(senderPage, "alice"); + await page.goto("/"); + await senderPage.goto("/"); + + await sendChannelMessage(senderPage, { + channelName: "general", + content: message, + mentionPubkeys: [TEST_IDENTITIES.tyler.pubkey], + }); + + await page.getByTestId("home-inbox-list").getByText(message).click({ + timeout: relayDeliveryTimeoutMs, + }); + await expect(page.getByTestId("home-inbox-detail")).toBeVisible(); + await expect(page.getByTestId("message-input")).toBeEnabled(); + + await page.getByTestId("message-input").fill(reply); + await page.getByTestId("send-message").click(); + + await expect(page.getByTestId("home-inbox-detail")).toContainText(reply); + } finally { + await targetContext.close(); + await senderContext.close(); + } }); test("creates a relay-backed stream", async ({ page }) => {