From eb50a5fcd5d3e0cb3d9514c51817116030006b09 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 9 Mar 2026 18:49:10 -0700 Subject: [PATCH 1/2] Add desktop Home feed --- desktop/.gitignore | 2 + desktop/src-tauri/src/lib.rs | 77 +++ desktop/src/app/AppShell.tsx | 167 ++++-- desktop/src/features/chat/ui/ChatHeader.tsx | 18 +- desktop/src/features/home/hooks.ts | 13 + desktop/src/features/home/ui/HomeView.tsx | 507 ++++++++++++++++++ .../src/features/sidebar/ui/AppSidebar.tsx | 42 +- desktop/src/shared/api/tauri.ts | 62 +++ desktop/src/shared/api/types.ts | 42 ++ 9 files changed, 872 insertions(+), 58 deletions(-) create mode 100644 desktop/src/features/home/hooks.ts create mode 100644 desktop/src/features/home/ui/HomeView.tsx diff --git a/desktop/.gitignore b/desktop/.gitignore index ce03b6e00..8c39bf90f 100644 --- a/desktop/.gitignore +++ b/desktop/.gitignore @@ -14,6 +14,8 @@ dist-ssr playwright-report test-results *.local +playwright-report +test-results # Editor directories and files .vscode/* diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index aac8c8295..3ebd5ed75 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -33,6 +33,50 @@ struct CreateChannelBody<'a> { description: Option<&'a str>, } +#[derive(Serialize)] +struct GetFeedQuery<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + types: Option<&'a str>, +} + +#[derive(Serialize, Deserialize)] +pub struct FeedItemInfo { + pub id: String, + pub kind: u32, + pub pubkey: String, + pub content: String, + pub created_at: u64, + pub channel_id: Option, + pub channel_name: String, + pub tags: Vec>, + pub category: String, +} + +#[derive(Serialize, Deserialize)] +pub struct FeedSections { + pub mentions: Vec, + pub needs_action: Vec, + pub activity: Vec, + pub agent_activity: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct FeedMeta { + pub since: i64, + pub total: u64, + pub generated_at: i64, +} + +#[derive(Serialize, Deserialize)] +pub struct FeedResponse { + pub feed: FeedSections, + pub meta: FeedMeta, +} + fn relay_ws_url() -> String { std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) } @@ -197,6 +241,38 @@ async fn create_channel( .map_err(|e| format!("parse failed: {e}")) } +#[tauri::command] +async fn get_feed( + since: Option, + limit: Option, + types: Option, + state: tauri::State<'_, AppState>, +) -> Result { + let pubkey_hex = auth_pubkey_header(&state)?; + let url = format!("{}{}", relay_api_base_url(), "/api/feed"); + let response = state + .http_client + .get(url) + .header("X-Pubkey", pubkey_hex) + .query(&GetFeedQuery { + since, + limit, + types: types.as_deref(), + }) + .send() + .await + .map_err(|e| format!("request failed: {e}"))?; + + if !response.status().is_success() { + return Err(relay_error_message(response).await); + } + + response + .json::() + .await + .map_err(|e| format!("parse failed: {e}")) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let app_state = AppState { @@ -222,6 +298,7 @@ pub fn run() { create_auth_event, get_channels, create_channel, + get_feed, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 3b1e68543..35667a66b 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -6,6 +6,8 @@ import { useChannelsQuery, useSelectedChannel, } from "@/features/channels/hooks"; +import { useHomeFeedQuery } from "@/features/home/hooks"; +import { HomeView } from "@/features/home/ui/HomeView"; import { useChannelMessagesQuery, useChannelSubscription, @@ -18,8 +20,12 @@ import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; import { useIdentityQuery } from "@/shared/api/hooks"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; +type AppView = "home" | "channel"; + export function AppShell() { + const [selectedView, setSelectedView] = React.useState("home"); const identityQuery = useIdentityQuery(); + const homeFeedQuery = useHomeFeedQuery(); const channelsQuery = useChannelsQuery(); const channels = channelsQuery.data ?? []; const { selectedChannel, setSelectedChannelId } = useSelectedChannel( @@ -27,29 +33,37 @@ export function AppShell() { null, ); const createChannelMutation = useCreateChannelMutation(); + const activeChannel = selectedView === "channel" ? selectedChannel : null; - const messagesQuery = useChannelMessagesQuery(selectedChannel); - useChannelSubscription(selectedChannel); + const messagesQuery = useChannelMessagesQuery(activeChannel); + useChannelSubscription(activeChannel); const sendMessageMutation = useSendMessageMutation( - selectedChannel, + activeChannel, identityQuery.data, ); + const homeUrgentCount = + (homeFeedQuery.data?.feed.mentions.length ?? 0) + + (homeFeedQuery.data?.feed.needsAction.length ?? 0); + const availableChannelIds = React.useMemo( + () => new Set(channels.map((channel) => channel.id)), + [channels], + ); const timelineMessages = React.useMemo( () => formatTimelineMessages( messagesQuery.data ?? [], - selectedChannel, + activeChannel, identityQuery.data?.pubkey, ), - [identityQuery.data?.pubkey, messagesQuery.data, selectedChannel], + [activeChannel, identityQuery.data?.pubkey, messagesQuery.data], ); - const channelDescription = selectedChannel - ? selectedChannel.channelType === "forum" - ? `${selectedChannel.description} Forum channels are listed, but this first pass only wires message streams and DMs.` - : selectedChannel.description + const channelDescription = activeChannel + ? activeChannel.channelType === "forum" + ? `${activeChannel.description} Forum channels are listed, but this first pass only wires message streams and DMs.` + : activeChannel.description : "Connect to the relay to browse channels and read messages."; return ( @@ -61,6 +75,7 @@ export function AppShell() { ? channelsQuery.error.message : undefined } + homeUrgentCount={homeUrgentCount} isLoading={channelsQuery.isLoading} isCreatingChannel={createChannelMutation.isPending} onCreateChannel={async ({ description, name }) => { @@ -71,58 +86,106 @@ export function AppShell() { visibility: "open", }); - React.startTransition(() => setSelectedChannelId(createdChannel.id)); + React.startTransition(() => { + setSelectedChannelId(createdChannel.id); + setSelectedView("channel"); + }); + }} + onSelectHome={() => { + React.startTransition(() => { + setSelectedView("home"); + }); + + void homeFeedQuery.refetch(); }} onSelectChannel={(channelId) => { - React.startTransition(() => setSelectedChannelId(channelId)); + React.startTransition(() => { + setSelectedChannelId(channelId); + setSelectedView("channel"); + }); }} selectedChannelId={selectedChannel?.id ?? null} + selectedView={selectedView} /> - - -
- - { - await sendMessageMutation.mutateAsync(content); - }} - placeholder={ - selectedChannel?.channelType === "forum" - ? "Forum posting is not wired in this pass." - : selectedChannel - ? `Message #${selectedChannel.name}` - : "Select a channel" - } + ) : ( + + )} + +
+ {selectedView === "home" ? ( + { + React.startTransition(() => { + setSelectedChannelId(channelId); + setSelectedView("channel"); + }); + }} + onRefresh={() => { + void homeFeedQuery.refetch(); + }} + /> + ) : ( + <> + + { + await sendMessageMutation.mutateAsync(content); + }} + placeholder={ + activeChannel?.channelType === "forum" + ? "Forum posting is not wired in this pass." + : activeChannel + ? `Message #${activeChannel.name}` + : "Select a channel" + } + /> + + )}
diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index cf5c496c0..7318be39a 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -1,4 +1,4 @@ -import { CircleDot, FileText, Hash } from "lucide-react"; +import { CircleDot, FileText, Hash, Home } from "lucide-react"; import type { ChannelType } from "@/shared/api/types"; import { SidebarTrigger } from "@/shared/ui/sidebar"; @@ -7,9 +7,20 @@ type ChatHeaderProps = { title: string; description: string; channelType?: ChannelType; + mode?: "home" | "channel"; }; -function ChannelIcon({ channelType }: { channelType?: ChannelType }) { +function ChannelIcon({ + channelType, + mode = "channel", +}: { + channelType?: ChannelType; + mode?: "home" | "channel"; +}) { + if (mode === "home") { + return ; + } + if (channelType === "dm") { return ; } @@ -25,6 +36,7 @@ export function ChatHeader({ title, description, channelType, + mode = "channel", }: ChatHeaderProps) { return (
- +

getHomeFeed({ limit: 12 }), + staleTime: 15_000, + gcTime: 5 * 60 * 1_000, + refetchInterval: 30_000, + }); +} diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx new file mode 100644 index 000000000..abc034d95 --- /dev/null +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -0,0 +1,507 @@ +import { + Activity, + AtSign, + Bot, + CircleAlert, + RefreshCcw, + Sparkles, + type LucideIcon, +} from "lucide-react"; + +import type { FeedItem, HomeFeedResponse } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Markdown } from "@/shared/ui/markdown"; +import { Skeleton } from "@/shared/ui/skeleton"; + +const relativeTimeFormatter = new Intl.RelativeTimeFormat("en-US", { + numeric: "auto", +}); + +function truncatePubkey(pubkey: string) { + return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`; +} + +function formatActor(pubkey: string, currentPubkey: string | undefined) { + if (currentPubkey && pubkey === currentPubkey) { + return "You"; + } + + return truncatePubkey(pubkey); +} + +function formatRelativeTime(unixSeconds: number) { + const diff = unixSeconds - Math.floor(Date.now() / 1_000); + const absoluteDiff = Math.abs(diff); + + if (absoluteDiff < 60) { + return relativeTimeFormatter.format(diff, "second"); + } + + if (absoluteDiff < 60 * 60) { + return relativeTimeFormatter.format(Math.round(diff / 60), "minute"); + } + + if (absoluteDiff < 60 * 60 * 24) { + return relativeTimeFormatter.format(Math.round(diff / (60 * 60)), "hour"); + } + + if (absoluteDiff < 60 * 60 * 24 * 7) { + return relativeTimeFormatter.format( + Math.round(diff / (60 * 60 * 24)), + "day", + ); + } + + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(unixSeconds * 1_000)); +} + +function formatUpdatedAt(unixSeconds: number) { + return new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + }).format(new Date(unixSeconds * 1_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 feedContent(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."; +} + +type FeedSectionProps = { + title: string; + description: string; + emptyTitle: string; + emptyDescription: string; + icon: LucideIcon; + items: FeedItem[]; + currentPubkey?: string; + availableChannelIds: ReadonlySet; + onOpenChannel: (channelId: string) => void; +}; + +function FeedSection({ + title, + description, + emptyTitle, + emptyDescription, + icon: Icon, + items, + currentPubkey, + availableChannelIds, + onOpenChannel, +}: FeedSectionProps) { + return ( +
+
+
+
+
+ +
+
+

+ {title} +

+

{description}

+
+
+
+
+ {items.length} +
+
+ +
+ {items.length === 0 ? ( +
+

{emptyTitle}

+

+ {emptyDescription} +

+
+ ) : null} + + {items.map((item) => { + const channelId = item.channelId; + const canOpenChannel = + channelId !== null && availableChannelIds.has(channelId); + + return ( +
+
+
+ +
+ +
+
+

+ {feedHeadline(item)} +

+

+ {formatActor(item.pubkey, currentPubkey)} +

+ {item.channelName ? ( +

+ {item.channelName} +

+ ) : null} +

+ {formatRelativeTime(item.createdAt)} +

+
+ + +
+ + {canOpenChannel ? ( +
+ +
+ ) : null} +
+ + {canOpenChannel ? ( +
+ +
+ ) : null} +
+ ); + })} +
+
+ ); +} + +function SummaryCard({ + title, + value, + icon: Icon, + tone, +}: { + title: string; + value: number; + icon: LucideIcon; + tone: "urgent" | "calm"; +}) { + return ( +
+
+
+ +
+
+

+ {title} +

+

{value}

+
+
+
+ ); +} + +function HomeLoadingState() { + return ( +
+
+
+ + + +
+ {["first", "second", "third", "fourth"].map((item) => ( + + ))} +
+
+ +
+ {["mentions", "actions", "activity", "agents"].map((section) => ( +
+ + +
+ {["a", "b", "c"].map((row) => ( + + ))} +
+
+ ))} +
+
+
+ ); +} + +type HomeViewProps = { + feed?: HomeFeedResponse; + isLoading?: boolean; + isRefreshing?: boolean; + errorMessage?: string; + currentPubkey?: string; + availableChannelIds: ReadonlySet; + onOpenChannel: (channelId: string) => void; + onRefresh: () => void; +}; + +export function HomeView({ + feed, + isLoading = false, + isRefreshing = false, + errorMessage, + currentPubkey, + availableChannelIds, + onOpenChannel, + onRefresh, +}: HomeViewProps) { + if (isLoading && !feed) { + return ; + } + + if (!feed) { + return ( +
+
+
+

+ Home feed unavailable +

+

+ {errorMessage ?? "The relay did not return a feed response."} +

+ +
+
+
+ ); + } + + const totalUrgent = feed.feed.mentions.length + feed.feed.needsAction.length; + + return ( +
+
+
+
+
+
+
+ +
+
+

+ Focus queue +

+

+ Mentions, reminders, channel activity, and agent work in one + feed. +

+
+
+ + {errorMessage ? ( +

{errorMessage}

+ ) : null} +
+ +
+

+ Updated {formatUpdatedAt(feed.meta.generatedAt)} +

+ +
+
+ +
+ + + + +
+
+ +
+ + + + +
+
+
+ ); +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 915bbb70d..519a02a22 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -1,4 +1,4 @@ -import { CircleDot, FileText, Hash, Plus } from "lucide-react"; +import { CircleDot, FileText, Hash, Home, Plus } from "lucide-react"; import * as React from "react"; import type { Channel } from "@/shared/api/types"; @@ -27,11 +27,14 @@ type AppSidebarProps = { isLoading: boolean; isCreatingChannel: boolean; errorMessage?: string; + homeUrgentCount?: number; selectedChannelId: string | null; + selectedView: "home" | "channel"; onCreateChannel: (input: { name: string; description?: string; }) => Promise; + onSelectHome: () => void; onSelectChannel: (channelId: string) => void; }; @@ -49,12 +52,14 @@ function SidebarChannelIcon({ channel }: { channel: Channel }) { function SidebarSection({ items, + isActiveChannel, selectedChannelId, title, testId, onSelectChannel, }: { items: Channel[]; + isActiveChannel: boolean; selectedChannelId: string | null; title: string; testId: string; @@ -73,7 +78,7 @@ function SidebarSection({ onSelectChannel(channel.id)} tooltip={channel.name} type="button" @@ -103,6 +108,7 @@ function StreamsSection({ onCreateChannel, onCancelCreate, onSelectChannel, + isActiveChannel, selectedChannelId, }: { items: Channel[]; @@ -118,6 +124,7 @@ function StreamsSection({ onCreateChannel: (event: React.FormEvent) => void; onCancelCreate: () => void; onSelectChannel: (channelId: string) => void; + isActiveChannel: boolean; selectedChannelId: string | null; }) { return ( @@ -194,7 +201,7 @@ function StreamsSection({ onSelectChannel(channel.id)} tooltip={channel.name} type="button" @@ -216,8 +223,11 @@ export function AppSidebar({ isLoading, isCreatingChannel, errorMessage, + homeUrgentCount, selectedChannelId, + selectedView, onCreateChannel, + onSelectHome, onSelectChannel, }: AppSidebarProps) { const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"]; @@ -316,6 +326,29 @@ export function AppSidebar({ + + + + + + + Home + {homeUrgentCount && homeUrgentCount > 0 ? ( + + {homeUrgentCount} + + ) : null} + + + + + + {isLoading ? ( Channels @@ -338,6 +371,7 @@ export function AppSidebar({ draftName={draftName} isCreateOpen={isCreateOpen} isCreatingChannel={isCreatingChannel} + isActiveChannel={selectedView === "channel"} items={streamChannels} onCancelCreate={() => { setCreateErrorMessage(undefined); @@ -364,6 +398,7 @@ export function AppSidebar({ selectedChannelId={selectedChannelId} /> { const identity = await invoke("get_identity"); @@ -58,6 +100,26 @@ export async function createChannel( return fromRawChannel(channel); } +export async function getHomeFeed( + input: GetHomeFeedInput = {}, +): Promise { + const response = await invoke("get_feed", input); + + return { + feed: { + mentions: response.feed.mentions.map(fromRawFeedItem), + needsAction: response.feed.needs_action.map(fromRawFeedItem), + activity: response.feed.activity.map(fromRawFeedItem), + agentActivity: response.feed.agent_activity.map(fromRawFeedItem), + }, + meta: { + since: response.meta.since, + total: response.meta.total, + generatedAt: response.meta.generated_at, + }, + }; +} + export async function signRelayEvent(input: { kind: number; content: string; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 7f5c050a3..add116a47 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -32,3 +32,45 @@ export type RelayEvent = { sig: string; pending?: boolean; }; + +export type FeedItemCategory = + | "mention" + | "needs_action" + | "activity" + | "agent_activity"; + +export type FeedItem = { + id: string; + kind: number; + pubkey: string; + content: string; + createdAt: number; + channelId: string | null; + channelName: string; + tags: string[][]; + category: FeedItemCategory; +}; + +export type HomeFeed = { + mentions: FeedItem[]; + needsAction: FeedItem[]; + activity: FeedItem[]; + agentActivity: FeedItem[]; +}; + +export type HomeFeedMeta = { + since: number; + total: number; + generatedAt: number; +}; + +export type HomeFeedResponse = { + feed: HomeFeed; + meta: HomeFeedMeta; +}; + +export type GetHomeFeedInput = { + since?: number; + limit?: number; + types?: string; +}; From ce6a193f5a36e1237dbe493ca540931e20b2e6a8 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 9 Mar 2026 19:28:50 -0700 Subject: [PATCH 2/2] Add desktop home feed e2e coverage --- desktop/src/testing/e2eBridge.ts | 164 +++++++++++++++++++++++++++++++ desktop/tests/e2e/smoke.spec.ts | 23 +++++ desktop/tests/e2e/stream.spec.ts | 14 +++ 3 files changed, 201 insertions(+) diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 2d339dde8..b46cd4852 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -26,6 +26,32 @@ type RawChannel = { participant_pubkeys: string[]; }; +type RawFeedItem = { + id: string; + kind: number; + pubkey: string; + content: string; + created_at: number; + channel_id: string | null; + channel_name: string; + tags: string[][]; + category: "mention" | "needs_action" | "activity" | "agent_activity"; +}; + +type RawHomeFeedResponse = { + feed: { + mentions: RawFeedItem[]; + needs_action: RawFeedItem[]; + activity: RawFeedItem[]; + agent_activity: RawFeedItem[]; + }; + meta: { + since: number; + total: number; + generated_at: number; + }; +}; + type WsHandler = (message: unknown) => void; type MockSocket = { @@ -334,6 +360,139 @@ async function handleCreateChannel( return response.json(); } +async function handleGetFeed( + args: { + since?: number; + limit?: number; + types?: string; + }, + config: E2eConfig | undefined, +): Promise { + const identity = getIdentity(config); + if (!identity) { + const now = Math.floor(Date.now() / 1000); + const limit = args.limit ?? 50; + const wantedTypes = + args.types + ?.split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0) ?? []; + const includeType = (type: string) => + wantedTypes.length === 0 || wantedTypes.includes(type); + + const mentions = includeType("mentions") + ? [ + { + id: "mock-feed-mention", + kind: 40001, + pubkey: + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f", + content: "Please review the release checklist.", + created_at: now - 90, + channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + channel_name: "general", + tags: [ + ["e", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"], + ["p", DEFAULT_MOCK_IDENTITY.pubkey], + ], + category: "mention" as const, + }, + ].slice(0, limit) + : []; + + const needsAction = includeType("needs_action") + ? [ + { + id: "mock-feed-reminder", + kind: 40007, + pubkey: + "0000000000000000000000000000000000000000000000000000000000000000", + content: "Reminder: update the launch plan before lunch.", + created_at: now - 15 * 60, + channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301", + channel_name: "agents", + tags: [ + ["e", "94a444a4-c0a3-5966-ab05-530c6ddc2301"], + ["p", DEFAULT_MOCK_IDENTITY.pubkey], + ], + category: "needs_action" as const, + }, + ].slice(0, limit) + : []; + + const activity = includeType("activity") + ? [ + { + id: "mock-feed-activity", + kind: 40001, + pubkey: + "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260", + content: "Engineering shipped the desktop build.", + created_at: now - 42 * 60, + channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9", + channel_name: "engineering", + tags: [["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]], + category: "activity" as const, + }, + ].slice(0, limit) + : []; + + const agentActivity = includeType("agent_activity") + ? [ + { + id: "mock-feed-agent", + kind: 43003, + pubkey: + "db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36", + content: "Agent progress: channel index complete.", + created_at: now - 2 * 60 * 60, + channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301", + channel_name: "agents", + tags: [["e", "94a444a4-c0a3-5966-ab05-530c6ddc2301"]], + category: "agent_activity" as const, + }, + ].slice(0, limit) + : []; + + return { + feed: { + mentions, + needs_action: needsAction, + activity, + agent_activity: agentActivity, + }, + meta: { + since: args.since ?? now - 7 * 24 * 60 * 60, + total: + mentions.length + + needsAction.length + + activity.length + + agentActivity.length, + generated_at: now, + }, + }; + } + + const url = new URL("/api/feed", getRelayHttpUrl(config)); + if (args.since !== undefined) { + url.searchParams.set("since", String(args.since)); + } + if (args.limit !== undefined) { + url.searchParams.set("limit", String(args.limit)); + } + if (args.types) { + url.searchParams.set("types", args.types); + } + + const response = await fetch(url, { + headers: { + "X-Pubkey": identity.pubkey, + }, + }); + await assertOk(response); + return response.json(); +} + async function connectRealSocket(args: { url?: string; onMessage: unknown }) { const wsId = nextSocketId++; const ws = new WebSocket(args.url ?? DEFAULT_RELAY_WS_URL); @@ -516,6 +675,11 @@ export function maybeInstallE2eTauriMocks() { return getRelayWsUrl(activeConfig); case "get_channels": return handleGetChannels(activeConfig); + case "get_feed": + return handleGetFeed( + (payload as Parameters[0]) ?? {}, + activeConfig, + ); case "create_channel": return handleCreateChannel( payload as Parameters[0], diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 8a3ef078b..179d13d9b 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -30,6 +30,29 @@ test("creates a new mocked stream", async ({ page }) => { await expect(page.getByTestId("chat-title")).toHaveText(channelName); }); +test("opens a mocked channel from the home feed", async ({ page }) => { + const mentionsSection = page.locator("section").filter({ + has: page.getByRole("heading", { name: "@Mentions" }), + }); + + await page.goto("/"); + + await expect(page.getByTestId("chat-title")).toHaveText("Home"); + await expect( + page.getByRole("heading", { name: "Focus queue" }), + ).toBeVisible(); + await expect( + page.getByText("Please review the release checklist."), + ).toBeVisible(); + + await mentionsSection.getByRole("button", { name: "Open" }).click(); + + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(page.getByTestId("message-timeline")).toContainText( + "Welcome to #general", + ); +}); + test("sends a mocked channel message", async ({ page }) => { const message = `Smoke message ${Date.now()}`; diff --git a/desktop/tests/e2e/stream.spec.ts b/desktop/tests/e2e/stream.spec.ts index eec846bf8..b5f40401c 100644 --- a/desktop/tests/e2e/stream.spec.ts +++ b/desktop/tests/e2e/stream.spec.ts @@ -17,6 +17,20 @@ 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("/"); + + await expect(page.getByTestId("chat-title")).toHaveText("Home"); + await expect( + page.getByRole("heading", { name: "Focus queue" }), + ).toBeVisible(); + await expect(page.getByRole("heading", { name: "@Mentions" })).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Needs Action" }), + ).toBeVisible(); +}); + test("creates a relay-backed stream", async ({ page }) => { const channelName = `desktop-e2e-${Date.now()}`;