Home feed polish: filters, mark done, compact layout (#108)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-03-18 14:55:33 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent ba1c8df5c6
commit 334eee9c67
5 changed files with 371 additions and 286 deletions
@@ -0,0 +1,229 @@
import { Check, type LucideIcon } from "lucide-react";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import type { FeedItem } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
const relativeTimeFormatter = new Intl.RelativeTimeFormat("en-US", {
numeric: "auto",
});
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 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;
emptyTitle: string;
emptyDescription: string;
icon: LucideIcon;
items: FeedItem[];
currentPubkey?: string;
profiles?: UserProfileLookup;
availableChannelIds: ReadonlySet<string>;
doneSet: ReadonlySet<string>;
showDoneAction: boolean;
onOpenChannel: (channelId: string) => void;
onMarkDone: (id: string) => void;
onUndoDone: (id: string) => void;
};
export function FeedSection({
title,
emptyTitle,
emptyDescription,
icon: Icon,
items,
currentPubkey,
profiles,
availableChannelIds,
doneSet,
showDoneAction,
onOpenChannel,
onMarkDone,
onUndoDone,
}: FeedSectionProps) {
return (
<section>
<div className="flex items-center gap-2 pb-2">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{title}
</h2>
<span className="text-xs text-muted-foreground/70">{items.length}</span>
</div>
{items.length === 0 ? (
<div className="rounded-md border border-dashed border-border/60 px-4 py-5 text-center">
<p className="text-sm font-medium text-muted-foreground">
{emptyTitle}
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
{emptyDescription}
</p>
</div>
) : (
<div className="divide-y divide-border/60 rounded-md border border-border/60">
{items.map((item) => {
const channelId = item.channelId;
const canOpenChannel =
channelId !== null && availableChannelIds.has(channelId);
const isDone = doneSet.has(item.id);
return (
<div
className={`group relative px-3 py-2.5 transition-colors hover:bg-muted/40 ${isDone ? "opacity-50" : ""} ${canOpenChannel ? "cursor-pointer" : ""}`}
key={item.id}
>
{canOpenChannel ? (
<button
aria-label={`Open ${item.channelName || "channel"}`}
className="absolute inset-0"
onClick={() => {
if (channelId) {
onOpenChannel(channelId);
}
}}
type="button"
/>
) : null}
<div className="pointer-events-none relative flex min-w-0 items-center gap-2">
<span
className={`text-[13px] font-medium ${isDone ? "line-through text-muted-foreground" : ""}`}
>
{feedHeadline(item)}
</span>
<span className="text-[11px] text-muted-foreground">
{resolveUserLabel({
pubkey: item.pubkey,
currentPubkey,
profiles,
preferResolvedSelfLabel: true,
})}
</span>
{item.channelName ? (
<span className="text-[11px] text-primary/80">
#{item.channelName}
</span>
) : null}
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground/60">
{formatRelativeTime(item.createdAt)}
</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)}
/>
{showDoneAction ? (
<Button
aria-label={isDone ? "Undo done" : "Mark done"}
onClick={() => {
if (isDone) {
onUndoDone(item.id);
} else {
onMarkDone(item.id);
}
}}
size="icon"
type="button"
variant="ghost"
className={`pointer-events-auto absolute right-1.5 top-1.5 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100 ${isDone ? "text-green-500 opacity-100" : "text-muted-foreground"}`}
>
<Check className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
);
})}
</div>
)}
</section>
);
}
+79 -281
View File
@@ -1,266 +1,26 @@
import { AtSign, CircleAlert, RefreshCcw, type LucideIcon } from "lucide-react";
import * as React from "react";
import { AtSign, CircleAlert, RefreshCcw } from "lucide-react";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { useFeedItemState } from "@/features/home/useFeedItemState";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
import type { HomeFeedResponse } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import { Skeleton } from "@/shared/ui/skeleton";
import { FeedSection } from "./FeedSection";
const relativeTimeFormatter = new Intl.RelativeTimeFormat("en-US", {
numeric: "auto",
});
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 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;
profiles?: UserProfileLookup;
availableChannelIds: ReadonlySet<string>;
onOpenChannel: (channelId: string) => void;
};
function FeedSection({
title,
description,
emptyTitle,
emptyDescription,
icon: Icon,
items,
currentPubkey,
profiles,
availableChannelIds,
onOpenChannel,
}: FeedSectionProps) {
return (
<section className="rounded-xl border border-border/80 bg-card/80 p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="h-4 w-4" />
</div>
<div>
<h2 className="text-base font-semibold tracking-tight">
{title}
</h2>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</div>
</div>
<div className="rounded-full bg-muted px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{items.length}
</div>
</div>
<div className="mt-5 space-y-3">
{items.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/80 bg-background/60 px-5 py-7 text-center">
<p className="text-sm font-semibold tracking-tight">{emptyTitle}</p>
<p className="mt-2 text-sm text-muted-foreground">
{emptyDescription}
</p>
</div>
) : null}
{items.map((item) => {
const channelId = item.channelId;
const canOpenChannel =
channelId !== null && availableChannelIds.has(channelId);
return (
<article
className="rounded-lg border border-border/70 bg-background/70 p-4 shadow-sm"
key={item.id}
>
<div className="flex gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-secondary/70 text-secondary-foreground">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold tracking-tight">
{feedHeadline(item)}
</h3>
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
{resolveUserLabel({
pubkey: item.pubkey,
currentPubkey,
profiles,
preferResolvedSelfLabel: true,
})}
</p>
{item.channelName ? (
<p className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-primary">
{item.channelName}
</p>
) : null}
<p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
{formatRelativeTime(item.createdAt)}
</p>
</div>
<Markdown
className="mt-2 max-w-none"
compact
content={feedContent(item)}
/>
</div>
{canOpenChannel ? (
<div className="hidden shrink-0 sm:block">
<Button
onClick={() => {
if (channelId) {
onOpenChannel(channelId);
}
}}
size="sm"
type="button"
variant="outline"
>
Open
</Button>
</div>
) : null}
</div>
{canOpenChannel ? (
<div className="mt-3 sm:hidden">
<Button
className="w-full"
onClick={() => {
if (channelId) {
onOpenChannel(channelId);
}
}}
size="sm"
type="button"
variant="outline"
>
Open channel
</Button>
</div>
) : null}
</article>
);
})}
</div>
</section>
);
}
type FeedFilter = "all" | "mention" | "needs_action";
function HomeLoadingState() {
return (
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4 sm:px-6">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<div className="rounded-xl border border-border/80 bg-card/80 p-5 shadow-sm">
<Skeleton className="h-6 w-32" />
<Skeleton className="mt-3 h-4 w-full max-w-lg" />
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 sm:px-6">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4">
<div className="grid gap-4 xl:grid-cols-2">
{["mentions", "actions"].map((section) => (
<div
className="rounded-xl border border-border/80 bg-card/80 p-5 shadow-sm"
key={section}
>
<Skeleton className="h-6 w-32" />
<Skeleton className="mt-3 h-4 w-full max-w-xs" />
<div className="mt-5 space-y-3">
<div key={section}>
<Skeleton className="mb-2 h-4 w-24" />
<div className="space-y-0 rounded-md border border-border/60">
{["a", "b", "c"].map((row) => (
<Skeleton className="h-28 rounded-lg" key={row} />
<Skeleton className="h-16" key={row} />
))}
</div>
</div>
@@ -271,6 +31,12 @@ function HomeLoadingState() {
);
}
const FILTER_OPTIONS: { value: FeedFilter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "mention", label: "Mentions" },
{ value: "needs_action", label: "Needs Action" },
];
type HomeViewProps = {
feed?: HomeFeedResponse;
isLoading?: boolean;
@@ -290,6 +56,9 @@ export function HomeView({
onOpenChannel,
onRefresh,
}: HomeViewProps) {
const [filter, setFilter] = React.useState<FeedFilter>("all");
const { doneSet, markDone, undoDone } = useFeedItemState(currentPubkey);
const feedItems = feed
? [...feed.feed.mentions, ...feed.feed.needsAction]
: [];
@@ -307,9 +76,9 @@ export function HomeView({
if (!feed) {
return (
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4 sm:px-6">
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 sm:px-6">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-6 py-8 shadow-sm">
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-5">
<p className="text-base font-semibold tracking-tight">
Home feed unavailable
</p>
@@ -326,34 +95,63 @@ export function HomeView({
);
}
const showMentions = filter === "all" || filter === "mention";
const showNeedsAction = filter === "all" || filter === "needs_action";
const singleColumn = filter !== "all";
return (
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4 sm:px-6">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
<div className="grid gap-4 xl:grid-cols-2">
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
description="Messages where your pubkey was tagged."
emptyDescription="When someone mentions you in an accessible channel, it will land here."
emptyTitle="No mentions right now"
icon={AtSign}
items={feed.feed.mentions}
onOpenChannel={onOpenChannel}
title="@Mentions"
/>
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
description="Approvals and reminders that need you."
emptyDescription="Workflow approval requests and reminders will appear here."
emptyTitle="Nothing needs action"
icon={CircleAlert}
items={feed.feed.needsAction}
onOpenChannel={onOpenChannel}
title="Needs Action"
/>
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 sm:px-6">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4">
<div className="flex items-center gap-1.5">
{FILTER_OPTIONS.map((option) => (
<Button
key={option.value}
onClick={() => setFilter(option.value)}
size="sm"
type="button"
variant={filter === option.value ? "default" : "ghost"}
className="h-7 px-2.5 text-xs"
>
{option.label}
</Button>
))}
</div>
<div className={`grid gap-5 ${singleColumn ? "" : "xl:grid-cols-2"}`}>
{showMentions ? (
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="When someone mentions you, it will land here."
emptyTitle="No mentions right now"
icon={AtSign}
items={feed.feed.mentions}
onMarkDone={markDone}
onOpenChannel={onOpenChannel}
onUndoDone={undoDone}
showDoneAction={false}
title="Mentions"
/>
) : null}
{showNeedsAction ? (
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="Approval requests and reminders will appear here."
emptyTitle="Nothing needs action"
icon={CircleAlert}
items={feed.feed.needsAction}
onMarkDone={markDone}
onOpenChannel={onOpenChannel}
onUndoDone={undoDone}
showDoneAction={true}
title="Needs Action"
/>
) : null}
</div>
</div>
</div>
@@ -0,0 +1,58 @@
import * as React from "react";
const DONE_STORAGE_KEY = "sprout-home-feed-done.v1";
const MAX_ITEMS = 500;
function doneStorageKey(pubkey: string) {
return `${DONE_STORAGE_KEY}:${pubkey}`;
}
function readStoredIds(key: string): string[] {
if (typeof window === "undefined") return [];
const raw = window.localStorage.getItem(key);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? parsed
.filter((v): v is string => typeof v === "string")
.slice(-MAX_ITEMS)
: [];
} catch {
return [];
}
}
function writeStoredIds(key: string, ids: string[]) {
if (typeof window === "undefined") return;
window.localStorage.setItem(key, JSON.stringify(ids.slice(-MAX_ITEMS)));
}
export function useFeedItemState(pubkey: string | undefined) {
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
const key = doneStorageKey(normalizedPubkey);
const [doneIds, setDoneIds] = React.useState<string[]>(() =>
readStoredIds(key),
);
React.useEffect(() => {
setDoneIds(readStoredIds(doneStorageKey(normalizedPubkey)));
}, [normalizedPubkey]);
React.useEffect(() => {
writeStoredIds(doneStorageKey(normalizedPubkey), doneIds);
}, [normalizedPubkey, doneIds]);
const doneSet = React.useMemo(() => new Set(doneIds), [doneIds]);
const markDone = React.useCallback((id: string) => {
setDoneIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
}, []);
const undoDone = React.useCallback((id: string) => {
setDoneIds((prev) => prev.filter((v) => v !== id));
}, []);
return { doneSet, markDone, undoDone };
}
+4 -4
View File
@@ -100,18 +100,18 @@ 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" }),
has: page.getByRole("heading", { name: "Mentions" }),
});
await page.goto("/");
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expect(page.getByRole("heading", { name: "@Mentions" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Mentions" })).toBeVisible();
await expect(
page.getByText("Please review the release checklist."),
).toBeVisible();
await mentionsSection.getByRole("button", { name: "Open" }).click();
await mentionsSection.getByRole("button", { name: "Open general" }).click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("message-timeline")).toContainText(
@@ -121,7 +121,7 @@ test("opens a mocked channel from the home feed", async ({ page }) => {
test("home feed renders resolved author labels", async ({ page }) => {
const mentionsSection = page.locator("section").filter({
has: page.getByRole("heading", { name: "@Mentions" }),
has: page.getByRole("heading", { name: "Mentions" }),
});
await page.goto("/");
+1 -1
View File
@@ -102,7 +102,7 @@ test("loads the home feed from the relay", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expect(page.getByRole("heading", { name: "@Mentions" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Mentions" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Needs Action" }),
).toBeVisible();