feat(home): add activity and agent feed sections with deep-linking (#330)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-15 14:44:21 -06:00
committed by GitHub
parent 811c8f3f07
commit 3050531e7e
9 changed files with 265 additions and 42 deletions

View File

@@ -2,15 +2,56 @@ 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 } = useAppNavigation();
const { goChannel, goPulse, openSearchHit } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const identityQuery = useIdentityQuery();
const channels = channelsQuery.data ?? [];
@@ -20,8 +61,17 @@ function HomeRouteComponent() {
<HomeScreen
availableChannelIds={availableChannelIds}
currentPubkey={identityQuery.data?.pubkey}
onOpenChannel={(channelId) => {
void goChannel(channelId);
onOpenFeedItem={(item) => {
if (!item.channelId) {
return;
}
if (canOpenFeedItemAsExactEvent(item)) {
void openSearchHit(toFeedItemSearchHit(item));
return;
}
void goChannel(item.channelId);
}}
onOpenPulse={() => {
void goPulse();

View File

@@ -5,7 +5,11 @@ import { getHomeFeed } from "@/shared/api/tauri";
export function useHomeFeedQuery() {
return useQuery({
queryKey: ["home-feed"],
queryFn: () => getHomeFeed({ limit: 12, types: "mentions,needs_action" }),
queryFn: () =>
getHomeFeed({
limit: 12,
types: "mentions,needs_action,activity,agent_activity",
}),
staleTime: 15_000,
gcTime: 5 * 60 * 1_000,
refetchInterval: 30_000,

View File

@@ -5,6 +5,18 @@ import {
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import type { FeedItem } from "@/shared/api/types";
import {
KIND_APPROVAL_REQUEST,
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_REMINDER,
} from "@/shared/constants/kinds";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
@@ -46,25 +58,25 @@ function formatRelativeTime(unixSeconds: number) {
function feedHeadline(item: FeedItem) {
switch (item.kind) {
case 40007:
case KIND_REMINDER:
return "Reminder";
case 43001:
case KIND_JOB_REQUEST:
return "Job requested";
case 43002:
case KIND_JOB_ACCEPTED:
return "Job accepted";
case 43003:
case KIND_JOB_PROGRESS:
return "Progress update";
case 43004:
case KIND_JOB_RESULT:
return "Job result";
case 43005:
case KIND_JOB_CANCEL:
return "Job cancelled";
case 43006:
case KIND_JOB_ERROR:
return "Job failed";
case 45001:
case KIND_FORUM_POST:
return "Forum post";
case 45003:
case KIND_FORUM_COMMENT:
return "Forum reply";
case 46010:
case KIND_APPROVAL_REQUEST:
return "Approval requested";
default:
if (item.category === "mention") {
@@ -85,11 +97,11 @@ function feedContent(item: FeedItem) {
return content;
}
if (item.kind === 46010) {
if (item.kind === KIND_APPROVAL_REQUEST) {
return "A workflow is waiting for approval.";
}
if (item.kind === 40007) {
if (item.kind === KIND_REMINDER) {
return "A reminder is waiting for you.";
}
@@ -107,7 +119,7 @@ type FeedSectionProps = {
availableChannelIds: ReadonlySet<string>;
doneSet: ReadonlySet<string>;
showDoneAction: boolean;
onOpenChannel: (channelId: string) => void;
onOpenItem: (item: FeedItem) => void;
onMarkDone: (id: string) => void;
onUndoDone: (id: string) => void;
};
@@ -123,7 +135,7 @@ export function FeedSection({
availableChannelIds,
doneSet,
showDoneAction,
onOpenChannel,
onOpenItem,
onMarkDone,
onUndoDone,
}: FeedSectionProps) {
@@ -164,10 +176,9 @@ export function FeedSection({
<button
aria-label={`Open ${item.channelName || "channel"}`}
className="absolute inset-0"
data-testid={`home-feed-open-${item.id}`}
onClick={() => {
if (channelId) {
onOpenChannel(channelId);
}
onOpenItem(item);
}}
type="button"
/>
@@ -197,12 +208,14 @@ export function FeedSection({
</span>
</div>
<Markdown
className="pointer-events-none relative mt-0.5 max-w-none text-[13px] leading-snug text-muted-foreground"
compact
content={feedContent(item)}
mentionNames={mentionNames}
/>
<div className="pointer-events-none relative mt-0.5 line-clamp-2">
<Markdown
className="max-w-none text-[13px] leading-snug text-muted-foreground"
compact
content={feedContent(item)}
mentionNames={mentionNames}
/>
</div>
{showDoneAction ? (
<Button

View File

@@ -1,3 +1,4 @@
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";
@@ -5,14 +6,14 @@ import { HomeView } from "@/features/home/ui/HomeView";
type HomeScreenProps = {
availableChannelIds: ReadonlySet<string>;
currentPubkey?: string;
onOpenChannel: (channelId: string) => void;
onOpenFeedItem: (item: FeedItem) => void;
onOpenPulse: () => void;
};
export function HomeScreen({
availableChannelIds,
currentPubkey,
onOpenChannel,
onOpenFeedItem,
onOpenPulse,
}: HomeScreenProps) {
const homeFeedQuery = useHomeFeedQuery();
@@ -20,7 +21,7 @@ export function HomeScreen({
return (
<>
<ChatHeader
description="Personalized feed for mentions, reminders, channel activity, and agent work."
description="Personalized activity feed for mentions, reminders, channel activity, and agent work."
mode="home"
title="Home"
/>
@@ -36,7 +37,7 @@ export function HomeScreen({
}
feed={homeFeedQuery.data}
isLoading={homeFeedQuery.isLoading}
onOpenChannel={onOpenChannel}
onOpenFeedItem={onOpenFeedItem}
onOpenPulse={onOpenPulse}
onRefresh={() => {
void homeFeedQuery.refetch();

View File

@@ -1,11 +1,11 @@
import * as React from "react";
import { AtSign, CircleAlert, RefreshCcw } from "lucide-react";
import { Activity, AtSign, Bot, CircleAlert, RefreshCcw } from "lucide-react";
import { useRelayAgentsQuery } from "@/features/agents/hooks";
import { useFeedItemState } from "@/features/home/useFeedItemState";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { useContactListQuery, useTimelineQuery } from "@/features/pulse/hooks";
import type { HomeFeedResponse } from "@/shared/api/types";
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
@@ -19,7 +19,12 @@ const RecentNotesSection = React.lazy(async () => {
return { default: module.RecentNotesSection };
});
type FeedFilter = "all" | "mention" | "needs_action";
type FeedFilter =
| "all"
| "mention"
| "needs_action"
| "activity"
| "agent_activity";
function HomeLoadingState() {
return (
@@ -46,6 +51,8 @@ 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" },
];
type HomeViewProps = {
@@ -54,7 +61,7 @@ type HomeViewProps = {
errorMessage?: string;
currentPubkey?: string;
availableChannelIds: ReadonlySet<string>;
onOpenChannel: (channelId: string) => void;
onOpenFeedItem: (item: FeedItem) => void;
onOpenPulse: () => void;
onRefresh: () => void;
};
@@ -65,7 +72,7 @@ export function HomeView({
errorMessage,
currentPubkey,
availableChannelIds,
onOpenChannel,
onOpenFeedItem,
onOpenPulse,
onRefresh,
}: HomeViewProps) {
@@ -105,7 +112,12 @@ export function HomeView({
);
const feedItems = feed
? [...feed.feed.mentions, ...feed.feed.needsAction]
? [
...feed.feed.mentions,
...feed.feed.needsAction,
...(feed.feed.activity ?? []),
...(feed.feed.agentActivity ?? []),
]
: [];
const feedProfilesQuery = useUsersBatchQuery(
feedItems.map((item) => item.pubkey),
@@ -142,6 +154,8 @@ 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 singleColumn = filter !== "all";
return (
@@ -186,7 +200,7 @@ export function HomeView({
icon={AtSign}
items={feed.feed.mentions}
onMarkDone={markDone}
onOpenChannel={onOpenChannel}
onOpenItem={onOpenFeedItem}
onUndoDone={undoDone}
showDoneAction={false}
title="Mentions"
@@ -203,12 +217,46 @@ export function HomeView({
icon={CircleAlert}
items={feed.feed.needsAction}
onMarkDone={markDone}
onOpenChannel={onOpenChannel}
onOpenItem={onOpenFeedItem}
onUndoDone={undoDone}
showDoneAction={true}
title="Needs Action"
/>
) : null}
{showActivity ? (
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="Recent channel messages and forum posts will show up here."
emptyTitle="No channel activity yet"
icon={Activity}
items={feed.feed.activity ?? []}
onMarkDone={markDone}
onOpenItem={onOpenFeedItem}
onUndoDone={undoDone}
showDoneAction={false}
title="Channel Activity"
/>
) : null}
{showAgentActivity ? (
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="Agent job requests, progress, and results will appear here."
emptyTitle="No agent updates yet"
icon={Bot}
items={feed.feed.agentActivity ?? []}
onMarkDone={markDone}
onOpenItem={onOpenFeedItem}
onUndoDone={undoDone}
showDoneAction={false}
title="Agent Updates"
/>
) : null}
</div>
</React.Suspense>
</div>

View File

@@ -10,9 +10,16 @@ import {
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import {
KIND_JOB_ACCEPTED,
KIND_JOB_CANCEL,
KIND_JOB_ERROR,
KIND_JOB_PROGRESS,
KIND_JOB_REQUEST,
KIND_JOB_RESULT,
KIND_DELETION,
KIND_REACTION,
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
KIND_STREAM_MESSAGE_EDIT,
KIND_STREAM_MESSAGE_DIFF,
KIND_SYSTEM_MESSAGE,
@@ -25,8 +32,15 @@ const HEX_RE = /^[0-9a-f]+$/i;
function isTimelineContentEvent(event: RelayEvent) {
return (
event.kind === KIND_STREAM_MESSAGE ||
event.kind === KIND_STREAM_MESSAGE_V2 ||
event.kind === KIND_STREAM_MESSAGE_DIFF ||
event.kind === KIND_SYSTEM_MESSAGE
event.kind === KIND_SYSTEM_MESSAGE ||
event.kind === KIND_JOB_REQUEST ||
event.kind === KIND_JOB_ACCEPTED ||
event.kind === KIND_JOB_PROGRESS ||
event.kind === KIND_JOB_RESULT ||
event.kind === KIND_JOB_CANCEL ||
event.kind === KIND_JOB_ERROR
);
}

View File

@@ -4,9 +4,17 @@ export const KIND_STREAM_MESSAGE = 9;
export const KIND_STREAM_MESSAGE_V2 = 40002;
export const KIND_STREAM_MESSAGE_EDIT = 40003;
export const KIND_STREAM_MESSAGE_DIFF = 40008;
export const KIND_REMINDER = 40007;
export const KIND_SYSTEM_MESSAGE = 40099;
export const KIND_JOB_REQUEST = 43001;
export const KIND_JOB_ACCEPTED = 43002;
export const KIND_JOB_PROGRESS = 43003;
export const KIND_JOB_RESULT = 43004;
export const KIND_JOB_CANCEL = 43005;
export const KIND_JOB_ERROR = 43006;
export const KIND_FORUM_POST = 45001;
export const KIND_FORUM_COMMENT = 45003;
export const KIND_APPROVAL_REQUEST = 46010;
export const KIND_TYPING_INDICATOR = 20002;
// Keep this in sync with the Home-feed mention query in sprout-db.

View File

@@ -147,12 +147,94 @@ test("opens a mocked channel from the home feed", async ({ page }) => {
await mentionsSection.getByRole("button", { name: "Open general" }).click();
await expect(page).toHaveURL(
/#\/channels\/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50\?messageId=mock-feed-mention$/,
);
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("message-timeline")).toContainText(
"Welcome to #general",
"Please review the release checklist.",
);
});
test("home feed shows channel and agent activity sections", async ({
page,
}) => {
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-feed-open-mock-feed-agent").click();
await expect(page).toHaveURL(
/#\/channels\/94a444a4-c0a3-5966-ab05-530c6ddc2301\?messageId=mock-feed-agent$/,
);
await expect(page.getByTestId("chat-title")).toHaveText("agents");
await expect(page.getByTestId("message-timeline")).toContainText(
"Agent progress: channel index complete.",
);
});
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 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" }),

View File

@@ -117,7 +117,10 @@ test("loads the home feed from the relay", async ({ page }) => {
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Channel Activity" }),
).toHaveCount(0);
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Agent Updates" }),
).toBeVisible();
});
test("creates a relay-backed stream", async ({ page }) => {