feat(desktop): prototype Bestie Feed

Co-authored-by: Arjun Mahanti <arjun@squareup.com>
Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
Arjun Mahanti
2026-08-25 11:49:56 -04:00
parent 9d1e4b2576
commit b191f35f1d
11 changed files with 1142 additions and 25 deletions

View File

@@ -1,5 +1,7 @@
import { defineConfig, devices } from "@playwright/test";
const webPort = process.env.BUZZ_DESKTOP_E2E_WEB_PORT ?? "4173";
export default defineConfig({
testDir: "./tests/e2e",
timeout: 30_000,
@@ -10,7 +12,7 @@ export default defineConfig({
["html", { open: "never", outputFolder: "playwright-report" }],
],
use: {
baseURL: "http://127.0.0.1:4173",
baseURL: `http://127.0.0.1:${webPort}`,
screenshot: "only-on-failure",
trace: "on-first-retry",
video: "retain-on-failure",
@@ -31,6 +33,7 @@ export default defineConfig({
"**/channel-shared-header-backdrop.spec.ts",
"**/channel-composer-overflow.spec.ts",
"**/badge.spec.ts",
"**/bestie-feed.spec.ts",
"**/channel-browser.spec.ts",
"**/channel-add-screenshots.spec.ts",
"**/add-community-screenshots.spec.ts",
@@ -191,9 +194,9 @@ export default defineConfig({
},
],
webServer: {
command: "python3 -m http.server 4173 -d dist",
command: `python3 -m http.server ${webPort} -d dist`,
cwd: ".",
reuseExistingServer: !process.env.CI,
url: "http://127.0.0.1:4173",
url: `http://127.0.0.1:${webPort}`,
},
});

View File

@@ -15,6 +15,7 @@ type HomeRouteSearch = {
profile?: string;
profileTab?: string;
profileView?: string;
view?: "bestie";
};
function validateHomeSearch(search: Record<string, unknown>): HomeRouteSearch {
@@ -35,6 +36,7 @@ function validateHomeSearch(search: Record<string, unknown>): HomeRouteSearch {
typeof search.profileView === "string" && search.profileView.length > 0
? search.profileView
: undefined,
view: search.view === "bestie" ? "bestie" : undefined,
};
}
@@ -44,6 +46,8 @@ export const Route = createFileRoute("/")({
});
function HomeRouteComponent() {
const navigate = Route.useNavigate();
const { view } = Route.useSearch();
const { goChannel } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const identityQuery = useIdentityQuery();
@@ -93,10 +97,13 @@ function HomeRouteComponent() {
return (
<HomeScreen
availableChannelIds={availableChannelIds}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
onOpenInbox={() => void navigate({ search: {}, to: "/" })}
onOpenContext={(channelId, messageId, threadRootId) => {
void goChannel(channelId, { messageId, threadRootId });
}}
view={view}
/>
);
}

View File

@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
bestieItemSourceLabel,
filterBestieFeedItems,
getVisibleBestieFeedItems,
reduceBestiePanelState,
sortBestieFeedItems,
} from "./bestieFeed.ts";
const item = (overrides = {}) => ({
avatarUrl: null,
categories: ["activity"],
categoryLabel: "Activity",
channelLabel: "general",
conversationId: "conversation",
fullTimestampLabel: "Aug 25, 2026, 12:00 PM",
groupItems: [],
id: "item",
isActionRequired: false,
item: {
category: "activity",
channelId: "channel",
channelName: "general",
content: "Actual Buzz content",
createdAt: 10,
id: "item",
kind: 9,
pubkey: "a".repeat(64),
tags: [],
},
latestActivityAt: 10,
mentionNames: [],
preview: "Actual Buzz content",
senderLabel: "Alice",
subject: "Channel update",
timestampLabel: "12:00 PM",
unreadCount: 0,
...overrides,
});
test("sortBestieFeedItems ranks actual categories, then recency and id", () => {
const items = [
item({ id: "activity", latestActivityAt: 30 }),
item({
categories: ["mention"],
id: "mention",
latestActivityAt: 10,
}),
item({ id: "b", isActionRequired: true, latestActivityAt: 20 }),
item({ id: "a", isActionRequired: true, latestActivityAt: 20 }),
];
assert.deepEqual(
sortBestieFeedItems(items).map(({ id }) => id),
["a", "b", "mention", "activity"],
);
assert.equal(items[0].id, "activity");
});
test("filterBestieFeedItems derives views from real Inbox categories", () => {
const items = [
item({ id: "message" }),
item({ id: "task", isActionRequired: true }),
item({ categories: ["agent_activity"], id: "agent" }),
];
assert.deepEqual(
filterBestieFeedItems(items, "messages").map(({ id }) => id),
["message"],
);
assert.deepEqual(
filterBestieFeedItems(items, "tasks").map(({ id }) => id),
["task", "agent"],
);
});
test("getVisibleBestieFeedItems hides only active local snoozes", () => {
const items = [item({ id: "active" }), item({ id: "expired" })];
assert.deepEqual(
getVisibleBestieFeedItems(items, { active: 200, expired: 99 }, 100).map(
({ id }) => id,
),
["expired"],
);
});
test("bestieItemSourceLabel uses only source metadata with a neutral fallback", () => {
assert.equal(bestieItemSourceLabel(item()), "general");
assert.equal(
bestieItemSourceLabel(
item({ channelLabel: null, item: { ...item().item, channelName: "" } }),
),
"Buzz",
);
});
test("reduceBestiePanelState keeps only one floating panel open", () => {
const reply = reduceBestiePanelState(
{ mode: "closed" },
{ itemId: "message", type: "open-reply" },
);
assert.deepEqual(reply, { itemId: "message", mode: "reply" });
assert.deepEqual(
reduceBestiePanelState(reply, { itemId: "pr", type: "open-chat" }),
{ itemId: "pr", mode: "chat" },
);
assert.deepEqual(reduceBestiePanelState(reply, { type: "close" }), {
mode: "closed",
});
});

View File

@@ -0,0 +1,72 @@
import { isProjectInboxItem } from "@/features/home/lib/projectInbox";
import type { InboxItem } from "@/features/home/lib/inbox";
export type BestieFeedFilter = "all" | "messages" | "tasks";
export type BestiePanelState =
| { mode: "closed" }
| { itemId: string; mode: "reply" | "chat" };
export type BestiePanelAction =
| { itemId: string; type: "open-reply" | "open-chat" }
| { type: "close" };
function categoryRank(item: InboxItem) {
if (item.isActionRequired) return 4;
if (item.categories.includes("mention")) return 3;
if (item.categories.includes("agent_activity")) return 2;
return 1;
}
export function sortBestieFeedItems(items: readonly InboxItem[]) {
return [...items].sort(
(left, right) =>
categoryRank(right) - categoryRank(left) ||
right.latestActivityAt - left.latestActivityAt ||
left.id.localeCompare(right.id),
);
}
export function filterBestieFeedItems(
items: readonly InboxItem[],
filter: BestieFeedFilter,
) {
if (filter === "all") return [...items];
if (filter === "messages") {
return items.filter(
(item) =>
!item.isActionRequired &&
!item.categories.includes("agent_activity") &&
!isProjectInboxItem(item.item),
);
}
return items.filter(
(item) =>
item.isActionRequired ||
item.categories.includes("agent_activity") ||
isProjectInboxItem(item.item),
);
}
export function getVisibleBestieFeedItems(
items: readonly InboxItem[],
snoozedUntilById: Readonly<Record<string, number>>,
now: number,
) {
return items.filter((item) => (snoozedUntilById[item.id] ?? 0) <= now);
}
export function bestieItemSourceLabel(item: InboxItem) {
return item.channelLabel ?? (item.item.channelName.trim() || "Buzz");
}
export function reduceBestiePanelState(
_state: BestiePanelState,
action: BestiePanelAction,
): BestiePanelState {
if (action.type === "close") return { mode: "closed" };
return {
itemId: action.itemId,
mode: action.type === "open-reply" ? "reply" : "chat",
};
}

View File

@@ -0,0 +1,217 @@
import {
Bot,
Clock3,
GitPullRequest,
MessageSquareReply,
Sparkles,
} from "lucide-react";
import { bestieItemSourceLabel } from "@/features/bestie/lib/bestieFeed";
import type { InboxItem } from "@/features/home/lib/inbox";
import { isProjectInboxItem } from "@/features/home/lib/projectInbox";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
type BestieFeedCardProps = {
item: InboxItem;
onChat: () => void;
onReply: () => void;
onSnooze: () => void;
};
function actualSupportingContent(item: InboxItem) {
const seen = new Set<string>();
return [...item.groupItems]
.sort((left, right) => left.createdAt - right.createdAt)
.map((event) => event.content.trim())
.filter((content) => {
if (!content || seen.has(content)) return false;
seen.add(content);
return true;
})
.slice(-2);
}
function SupportingVisual({ item }: { item: InboxItem }) {
const content = actualSupportingContent(item);
const projectItem = isProjectInboxItem(item.item);
return (
<div
className="rounded-2xl border border-border/60 bg-muted/25 p-4"
data-testid="bestie-source-visual"
>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{projectItem ? (
<GitPullRequest className="h-4 w-4 text-violet-500" />
) : item.categories.includes("agent_activity") ? (
<Bot className="h-4 w-4" />
) : (
<MessageSquareReply className="h-4 w-4" />
)}
<span className="font-medium text-foreground">
{bestieItemSourceLabel(item)}
</span>
<span>·</span>
<span>{item.categoryLabel}</span>
{item.unreadCount > 0 ? <span>· {item.unreadCount} unread</span> : null}
</div>
{content.length > 0 ? (
<div className="mt-3 flex flex-col items-start gap-1.5">
{content.map((message) => (
<div
className="max-w-full rounded-2xl bg-muted/70 px-4 py-2.5 text-sm leading-relaxed text-foreground"
key={message}
>
{message}
</div>
))}
</div>
) : (
<p className="mt-3 text-sm text-muted-foreground">
This Buzz event has no text content.
</p>
)}
</div>
);
}
function IconAction({
children,
label,
onClick,
testId,
}: {
children: React.ReactNode;
label: string;
onClick: () => void;
testId: string;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={label}
className="h-10 w-10 rounded-full bg-muted/50"
data-testid={testId}
onClick={onClick}
size="icon"
type="button"
variant="ghost"
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
export function BestieFeedCard({
item,
onChat,
onReply,
onSnooze,
}: BestieFeedCardProps) {
const headingId = `bestie-card-heading-${item.id}`;
return (
<article
aria-labelledby={headingId}
className="rounded-[1.75rem] border border-border/55 bg-background/95 p-5 shadow-sm sm:p-7"
data-bestie-category={item.categories.join(" ")}
data-testid={`bestie-card-${item.id}`}
>
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<time dateTime={new Date(item.latestActivityAt * 1_000).toISOString()}>
{item.fullTimestampLabel}
</time>
<div className="flex flex-wrap gap-1.5">
{item.categories.map((category) => (
<Badge
className="normal-case tracking-normal"
key={category}
variant="secondary"
>
{category === "needs_action"
? "Needs action"
: category === "agent_activity"
? "Agent update"
: category === "mention"
? "Mention"
: "Activity"}
</Badge>
))}
</div>
</div>
<div className="mt-6 flex items-center gap-3">
{item.avatarUrl ? (
<img
alt=""
className="h-11 w-11 shrink-0 rounded-2xl object-cover"
src={item.avatarUrl}
/>
) : (
<div
aria-hidden="true"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-foreground text-xs font-semibold text-background"
>
{item.senderLabel.slice(0, 2).toUpperCase()}
</div>
)}
<div className="min-w-0">
<p className="truncate text-sm font-medium">{item.senderLabel}</p>
<p className="truncate text-xs text-muted-foreground">
{bestieItemSourceLabel(item)}
</p>
</div>
</div>
<h2
className="mt-5 text-balance text-xl font-semibold leading-tight tracking-tight sm:text-2xl"
id={headingId}
>
{item.subject}
</h2>
<p className="mt-2 max-w-2xl whitespace-pre-wrap text-sm leading-relaxed text-muted-foreground">
{item.preview}
</p>
<div className="mt-5">
<SupportingVisual item={item} />
</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<IconAction
label={`Snooze ${item.subject}`}
onClick={onSnooze}
testId={`bestie-snooze-${item.id}`}
>
<Clock3 />
</IconAction>
<IconAction
label={`Reply about ${item.subject}`}
onClick={onReply}
testId={`bestie-reply-${item.id}`}
>
<MessageSquareReply />
</IconAction>
</div>
<Button
className="h-10 rounded-full px-4"
data-testid={`bestie-chat-${item.id}`}
onClick={onChat}
type="button"
variant="secondary"
>
<span aria-hidden="true">🐝</span>
Chat with Bestie
<Sparkles className="h-4 w-4" />
</Button>
</div>
</article>
);
}

View File

@@ -0,0 +1,137 @@
import {
Inbox,
RefreshCcw,
Search,
SlidersHorizontal,
Sparkles,
} from "lucide-react";
import type { BestieFeedFilter } from "@/features/bestie/lib/bestieFeed";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
const FILTER_LABELS: Record<BestieFeedFilter, string> = {
all: "Bestie",
messages: "Messages",
tasks: "Tasks",
};
function greetingForHour(hour: number) {
if (hour < 12) return "Good morning";
if (hour < 18) return "Good afternoon";
return "Good evening";
}
export function BestieFeedHeader({
filter,
isLoading,
onFilterChange,
onOpenInbox,
onRefresh,
onSearchChange,
query,
}: {
filter: BestieFeedFilter;
isLoading: boolean;
onFilterChange: (filter: BestieFeedFilter) => void;
onOpenInbox: () => void;
onRefresh: () => void;
onSearchChange: (query: string) => void;
query: string;
}) {
return (
<>
<div className="mx-auto max-w-3xl">
<div aria-hidden="true" className="text-4xl">
🐝
</div>
<h1 className="mt-5 max-w-xl text-balance text-3xl font-medium leading-tight tracking-tight text-muted-foreground sm:text-4xl">
{greetingForHour(new Date().getHours())}. Here’s what’s happening in
your Buzz.
</h1>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Badge className="normal-case tracking-normal" variant="secondary">
Live from your Home feed
</Badge>
<Badge className="normal-case tracking-normal" variant="outline">
Prototype ranking · no generated summaries
</Badge>
</div>
</div>
<div className="sticky top-3 z-30 mx-auto mt-10 flex max-w-3xl flex-col gap-3 rounded-[1.4rem] border border-border/60 bg-background/90 p-2 shadow-sm backdrop-blur-xl sm:flex-row sm:items-center sm:justify-between">
<div
aria-label="Bestie Feed sections"
className="flex min-w-0 items-center gap-1 overflow-x-auto"
role="tablist"
>
{(Object.keys(FILTER_LABELS) as BestieFeedFilter[]).map(
(nextFilter) => (
<Button
aria-selected={filter === nextFilter}
className="rounded-full"
data-testid={`bestie-filter-${nextFilter}`}
key={nextFilter}
onClick={() => onFilterChange(nextFilter)}
role="tab"
size="sm"
type="button"
variant={filter === nextFilter ? "secondary" : "ghost"}
>
{nextFilter === "all" ? <Sparkles /> : null}
{FILTER_LABELS[nextFilter]}
</Button>
),
)}
<Button
className="rounded-full text-muted-foreground"
data-testid="bestie-open-inbox"
onClick={onOpenInbox}
size="sm"
type="button"
variant="ghost"
>
<Inbox />
Inbox
</Button>
</div>
<div className="flex items-center gap-1.5">
<div className="relative min-w-0 flex-1 sm:w-52 sm:flex-none">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="Search Bestie Feed"
className="h-8 rounded-full border-0 bg-muted/60 pl-8 shadow-none"
data-testid="bestie-search"
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search live feed"
value={query}
/>
</div>
<Button
aria-label="Refresh live Bestie Feed"
className="rounded-full text-muted-foreground"
onClick={onRefresh}
size="icon"
title="Refresh live feed"
type="button"
variant="ghost"
>
<RefreshCcw className={isLoading ? "animate-spin" : ""} />
</Button>
<Button
aria-label="Bestie Feed preferences"
className="rounded-full text-muted-foreground"
disabled
size="icon"
title="Feed preferences are coming next"
type="button"
variant="ghost"
>
<SlidersHorizontal />
</Button>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,170 @@
import { Bot, ExternalLink, SendHorizontal } from "lucide-react";
import * as React from "react";
import { bestieItemSourceLabel } from "@/features/bestie/lib/bestieFeed";
import type { BestiePanelState } from "@/features/bestie/lib/bestieFeed";
import type { InboxItem } from "@/features/home/lib/inbox";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/shared/ui/sheet";
import { Textarea } from "@/shared/ui/textarea";
type PanelMessage = {
author: "source" | "you";
content: string;
id: string;
};
function sourceMessages(item: InboxItem): PanelMessage[] {
return [...item.groupItems]
.sort((left, right) => left.createdAt - right.createdAt)
.filter((event) => event.content.trim().length > 0)
.map((event) => ({
author: "source" as const,
content: event.content.trim(),
id: event.id,
}));
}
export function BestieFeedPanel({
item,
onOpenChange,
onOpenSource,
state,
}: {
item: InboxItem | null;
onOpenChange: (open: boolean) => void;
onOpenSource: (item: InboxItem) => void;
state: BestiePanelState;
}) {
const mode = state.mode === "closed" ? null : state.mode;
const [draft, setDraft] = React.useState("");
const [localMessages, setLocalMessages] = React.useState<PanelMessage[]>([]);
if (!item || !mode) return null;
const isChat = mode === "chat";
const messages = isChat
? localMessages
: [...sourceMessages(item), ...localMessages];
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
const content = draft.trim();
if (!content) return;
setLocalMessages((current) => [
...current,
{ author: "you", content, id: `${item.id}-local-${current.length}` },
]);
setDraft("");
};
return (
<Sheet onOpenChange={onOpenChange} open>
<SheetContent
aria-describedby="bestie-panel-description"
className="flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-[30rem]"
data-testid={`bestie-${mode}-panel`}
side="right"
>
<SheetHeader className="border-b border-border/60 px-5 pb-4 pt-5 pr-14 text-left">
<div className="flex items-center gap-2">
{isChat ? <Bot className="h-4 w-4" /> : null}
<SheetTitle>
{isChat
? "Bestie · About this item"
: `Reply in ${bestieItemSourceLabel(item)}`}
</SheetTitle>
</div>
<SheetDescription id="bestie-panel-description">
{isChat
? "The exact Buzz item is pinned below. Agent chat is not connected yet."
: "This panel shows the real source conversation from Buzz."}
</SheetDescription>
<div className="mt-1 flex flex-wrap items-center gap-2">
<Badge className="normal-case tracking-normal" variant="outline">
{isChat
? "Prototype chat · no agent response"
: "Prototype composer · nothing is sent"}
</Badge>
{item.item.channelId ? (
<Button
onClick={() => onOpenSource(item)}
size="xs"
type="button"
variant="ghost"
>
Open source
<ExternalLink />
</Button>
) : null}
</div>
</SheetHeader>
<div className="border-b border-border/60 bg-muted/25 px-5 py-4">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
Pinned Buzz context
</p>
<p className="mt-1 text-sm font-semibold">{item.subject}</p>
<p className="mt-1 whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground">
{item.preview}
</p>
</div>
<div
aria-live="polite"
className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-5 py-5"
data-testid="bestie-panel-transcript"
>
{messages.length > 0 ? (
messages.map((message) => (
<div
className={
message.author === "you"
? "ml-auto max-w-[85%] rounded-2xl rounded-br-md bg-foreground px-4 py-2.5 text-sm text-background"
: "mr-auto max-w-[92%] whitespace-pre-wrap rounded-2xl rounded-bl-md bg-muted px-4 py-2.5 text-sm text-foreground"
}
data-author={message.author}
key={message.id}
>
{message.content}
</div>
))
) : (
<p className="my-auto text-center text-sm text-muted-foreground">
Bestie chat is not connected in this prototype. Add a question
here to test the interaction; it stays local and receives no
simulated answer.
</p>
)}
</div>
<form className="border-t border-border/60 p-4" onSubmit={handleSubmit}>
<Textarea
aria-label={isChat ? "Ask Bestie about this item" : "Write a reply"}
autoFocus
className="min-h-24 resize-none"
data-testid="bestie-panel-input"
onChange={(event) => setDraft(event.target.value)}
placeholder={isChat ? "Ask about this item…" : "Write a reply…"}
value={draft}
/>
<div className="mt-3 flex items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">
Local prototype only
</span>
<Button disabled={!draft.trim()} size="sm" type="submit">
<SendHorizontal />
{isChat ? "Add question" : "Add reply"}
</Button>
</div>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,250 @@
import * as React from "react";
import {
type BestieFeedFilter,
type BestiePanelState,
filterBestieFeedItems,
getVisibleBestieFeedItems,
reduceBestiePanelState,
sortBestieFeedItems,
} from "@/features/bestie/lib/bestieFeed";
import { BestieFeedCard } from "@/features/bestie/ui/BestieFeedCard";
import { BestieFeedHeader } from "@/features/bestie/ui/BestieFeedHeader";
import { BestieFeedPanel } from "@/features/bestie/ui/BestieFeedPanel";
import { buildInboxItems } from "@/features/home/lib/inbox";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import type { Channel, HomeFeedResponse } from "@/shared/api/types";
import { getThreadReference } from "@/features/messages/lib/threading";
import { Button } from "@/shared/ui/button";
function feedPubkeys(feed?: HomeFeedResponse) {
if (!feed) return [];
return [
...new Set(
[
...feed.feed.mentions,
...feed.feed.needsAction,
...feed.feed.activity,
...feed.feed.agentActivity,
].map((item) => item.pubkey),
),
];
}
type BestieFeedViewProps = {
channels: Channel[];
currentPubkey?: string;
errorMessage?: string;
feed?: HomeFeedResponse;
isLoading: boolean;
onOpenContext: (
channelId: string,
messageId: string,
threadRootId?: string | null,
) => void;
onOpenInbox: () => void;
onRefresh: () => void;
};
export function BestieFeedView({
channels,
currentPubkey,
errorMessage,
feed,
isLoading,
onOpenContext,
onOpenInbox,
onRefresh,
}: BestieFeedViewProps) {
const [filter, setFilter] = React.useState<BestieFeedFilter>("all");
const [query, setQuery] = React.useState("");
const [snoozedUntilById, setSnoozedUntilById] = React.useState<
Record<string, number>
>({});
const [lastSnoozedId, setLastSnoozedId] = React.useState<string | null>(null);
const [panelState, dispatchPanel] = React.useReducer(reduceBestiePanelState, {
mode: "closed",
} satisfies BestiePanelState);
const pubkeys = React.useMemo(() => feedPubkeys(feed), [feed]);
const profilesQuery = useUsersBatchQuery(pubkeys, {
enabled: pubkeys.length > 0,
});
const items = React.useMemo(
() =>
buildInboxItems({
channels,
currentPubkey,
feed,
profiles: profilesQuery.data?.profiles,
}),
[channels, currentPubkey, feed, profilesQuery.data?.profiles],
);
const visibleItems = React.useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return filterBestieFeedItems(
getVisibleBestieFeedItems(
sortBestieFeedItems(items),
snoozedUntilById,
Date.now(),
),
filter,
).filter((item) => {
if (!normalizedQuery) return true;
return [
item.senderLabel,
item.categoryLabel,
item.channelLabel ?? "",
item.preview,
item.subject,
].some((value) => value.toLowerCase().includes(normalizedQuery));
});
}, [filter, items, query, snoozedUntilById]);
const panelItem =
panelState.mode === "closed"
? null
: (items.find((item) => item.id === panelState.itemId) ?? null);
const lastSnoozedItem = lastSnoozedId
? (items.find((item) => item.id === lastSnoozedId) ?? null)
: null;
const openSource = React.useCallback(
(item: (typeof items)[number]) => {
const channelId = item.item.channelId;
if (!channelId) return;
const thread = getThreadReference(item.item.tags);
onOpenContext(channelId, item.item.id, thread.rootId);
},
[onOpenContext],
);
return (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-muted/20">
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<main className="mx-auto w-full max-w-4xl px-4 pb-16 pt-10 sm:px-7 sm:pt-14">
<BestieFeedHeader
filter={filter}
isLoading={isLoading}
onFilterChange={setFilter}
onOpenInbox={onOpenInbox}
onRefresh={onRefresh}
onSearchChange={setQuery}
query={query}
/>
{lastSnoozedItem ? (
<div
aria-live="polite"
className="mx-auto mt-4 flex max-w-3xl items-center justify-between gap-3 rounded-xl border border-border/60 bg-background/90 px-4 py-3 text-sm"
data-testid="bestie-snooze-notice"
>
<span className="truncate">
Snoozed “{lastSnoozedItem.subject}”
</span>
<Button
onClick={() => {
setSnoozedUntilById((current) => {
const next = { ...current };
delete next[lastSnoozedItem.id];
return next;
});
setLastSnoozedId(null);
}}
size="xs"
type="button"
variant="ghost"
>
Undo
</Button>
</div>
) : null}
<div
className="mx-auto mt-4 flex max-w-3xl flex-col gap-4"
data-testid="bestie-feed"
>
{!feed && isLoading ? (
<FeedState title="Loading your Buzz feed…" />
) : !feed ? (
<FeedState
action={<Button onClick={onRefresh}>Try again</Button>}
detail={errorMessage ?? "The Home feed is unavailable."}
title="Couldn’t load live Buzz data"
/>
) : visibleItems.length > 0 ? (
visibleItems.map((item) => (
<BestieFeedCard
item={item}
key={item.id}
onChat={() =>
dispatchPanel({ itemId: item.id, type: "open-chat" })
}
onReply={() =>
dispatchPanel({ itemId: item.id, type: "open-reply" })
}
onSnooze={() => {
setSnoozedUntilById((current) => ({
...current,
[item.id]: Date.now() + 4 * 60 * 60 * 1_000,
}));
setLastSnoozedId(item.id);
}}
/>
))
) : (
<FeedState
detail="Try another section, clear search, or refresh Home."
title="No matching live feed items"
/>
)}
</div>
</main>
</div>
<BestieFeedPanel
item={panelItem}
key={
panelState.mode === "closed"
? "closed"
: `${panelState.mode}:${panelState.itemId}`
}
onOpenChange={(open) => {
if (!open && panelState.mode !== "closed") {
const triggerTestId = `bestie-${panelState.mode}-${panelState.itemId}`;
dispatchPanel({ type: "close" });
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
document
.querySelector<HTMLButtonElement>(
`[data-testid="${triggerTestId}"]`,
)
?.focus();
});
});
}
}}
onOpenSource={openSource}
state={panelState}
/>
</div>
);
}
function FeedState({
action,
detail,
title,
}: {
action?: React.ReactNode;
detail?: string;
title: string;
}) {
return (
<div className="rounded-[1.75rem] border border-dashed border-border/70 bg-background/60 px-6 py-16 text-center">
<p className="font-medium">{title}</p>
{detail ? (
<p className="mt-1 text-sm text-muted-foreground">{detail}</p>
) : null}
{action ? <div className="mt-4">{action}</div> : null}
</div>
);
}

View File

@@ -1,9 +1,10 @@
import * as React from "react";
import { useAppShell } from "@/app/AppShellContext";
import { BestieFeedView } from "@/features/bestie/ui/BestieFeedView";
import { useHomeFeedQuery } from "@/features/home/hooks";
import { HomeView } from "@/features/home/ui/HomeView";
import type { HomeFeedResponse } from "@/shared/api/types";
import type { Channel, HomeFeedResponse } from "@/shared/api/types";
import {
isRelayUnreachableError,
RELAY_UNREACHABLE_MESSAGE,
@@ -11,18 +12,24 @@ import {
type HomeScreenProps = {
availableChannelIds: ReadonlySet<string>;
channels: Channel[];
currentPubkey?: string;
onOpenInbox: () => void;
onOpenContext: (
channelId: string,
messageId: string,
threadRootId?: string | null,
) => void;
view?: "bestie";
};
export function HomeScreen({
availableChannelIds,
channels,
currentPubkey,
onOpenInbox,
onOpenContext,
view,
}: HomeScreenProps) {
const homeFeedQuery = useHomeFeedQuery();
const { threadActivityFeedItems } = useAppShell();
@@ -45,27 +52,41 @@ export function HomeScreen({
};
}, [homeFeedQuery.data, threadActivityFeedItems]);
const errorMessage =
homeFeedQuery.error !== null && homeFeedQuery.error !== undefined
? isRelayUnreachableError(homeFeedQuery.error)
? RELAY_UNREACHABLE_MESSAGE
: homeFeedQuery.error instanceof Error
? homeFeedQuery.error.message
: undefined
: undefined;
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<HomeView
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
errorMessage={
homeFeedQuery.error !== null && homeFeedQuery.error !== undefined
? isRelayUnreachableError(homeFeedQuery.error)
? RELAY_UNREACHABLE_MESSAGE
: homeFeedQuery.error instanceof Error
? homeFeedQuery.error.message
: undefined
: undefined
}
feed={augmentedFeed}
isLoading={homeFeedQuery.isLoading}
onOpenContext={onOpenContext}
onRefresh={() => {
void homeFeedQuery.refetch();
}}
/>
{view === "bestie" ? (
<BestieFeedView
channels={channels}
currentPubkey={currentPubkey}
errorMessage={errorMessage}
feed={augmentedFeed}
isLoading={homeFeedQuery.isLoading}
onOpenContext={onOpenContext}
onOpenInbox={onOpenInbox}
onRefresh={() => void homeFeedQuery.refetch()}
/>
) : (
<HomeView
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
errorMessage={errorMessage}
feed={augmentedFeed}
isLoading={homeFeedQuery.isLoading}
onOpenContext={onOpenContext}
onRefresh={() => {
void homeFeedQuery.refetch();
}}
/>
)}
</div>
);
}

View File

@@ -1,4 +1,11 @@
import { Bell, Clock, Ellipsis, ExternalLink, MailOpen } from "lucide-react";
import {
Bell,
Clock,
Ellipsis,
ExternalLink,
MailOpen,
Sparkles,
} from "lucide-react";
import * as React from "react";
import {
@@ -25,6 +32,7 @@ import {
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import {
ContextMenu,
ContextMenuContent,
@@ -596,7 +604,7 @@ export function InboxListPane({
</PopoverContent>
</Popover>
</div>
<div className="order-1 flex shrink-0 items-center justify-start">
<div className="order-1 flex shrink-0 items-center justify-start gap-1.5">
<InboxFilterMenu
activeDraftCount={activeDraftCount}
dueReminderCount={dueReminderCount}
@@ -604,6 +612,19 @@ export function InboxListPane({
onFilterChange={onFilterChange}
reminderCount={reminders.length}
/>
<Button
aria-label="Open Bestie Feed"
asChild
className="h-7 rounded-full px-2 text-2xs text-muted-foreground"
data-testid="open-bestie-feed"
size="sm"
variant="ghost"
>
<a href="/#/?view=bestie">
<Sparkles className="h-3.5 w-3.5" />
Bestie
</a>
</Button>
</div>
</div>
</div>

View File

@@ -0,0 +1,107 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const LIVE_MENTION = "Please review the release checklist.";
const LIVE_REMINDER = "Reminder: update the launch plan before lunch.";
test("Bestie Feed projects only events returned by the Home feed", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("open-bestie-feed").click();
await expect(page).toHaveURL(/#\/\?view=bestie$/);
await expect(
page.getByRole("heading", { name: /what’s happening in your Buzz/i }),
).toBeVisible();
await expect(page.getByText("Live from your Home feed")).toBeVisible();
await expect(
page.getByText(LIVE_MENTION, { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText(LIVE_REMINDER, { exact: true }).first(),
).toBeVisible();
await expect(page.getByText(/Simon asked for the latest/)).toHaveCount(0);
await expect(page.getByText(/Cmd\+K is ready/)).toHaveCount(0);
await page.getByTestId("bestie-filter-messages").click();
await expect(
page.getByText(LIVE_MENTION, { exact: true }).first(),
).toBeVisible();
await expect(page.getByText(LIVE_REMINDER, { exact: true })).toHaveCount(0);
await page.getByTestId("bestie-filter-all").click();
const mentionCard = page
.getByText(LIVE_MENTION, { exact: true })
.first()
.locator("xpath=ancestor::article");
const snoozeTrigger = mentionCard.getByRole("button", {
name: /Snooze Mention/,
});
await snoozeTrigger.click();
await expect(page.getByText(LIVE_MENTION, { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: "Undo" }).click();
await expect(
page.getByText(LIVE_MENTION, { exact: true }).first(),
).toBeVisible();
const replyTrigger = page
.getByText(LIVE_MENTION, { exact: true })
.first()
.locator("xpath=ancestor::article")
.getByRole("button", { name: /Reply about Mention/ });
await replyTrigger.click();
const replyPanel = page.getByTestId("bestie-reply-panel");
await expect(replyPanel).toContainText(LIVE_MENTION);
await expect(replyPanel).toContainText("real source conversation from Buzz");
await page.keyboard.press("Escape");
await expect(replyTrigger).toBeFocused();
const chatTrigger = page
.getByText(LIVE_MENTION, { exact: true })
.first()
.locator("xpath=ancestor::article")
.getByRole("button", { name: "Chat with Bestie" });
await chatTrigger.click();
const chatPanel = page.getByTestId("bestie-chat-panel");
await expect(chatPanel).toContainText(LIVE_MENTION);
await expect(chatPanel).toContainText("no agent response");
await page.getByTestId("bestie-panel-input").fill("What should I do?");
await page.getByRole("button", { name: "Add question" }).click();
await expect(chatPanel.locator('[data-author="you"]')).toContainText(
"What should I do?",
);
await expect(chatPanel.locator('[data-author="bestie"]')).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByTestId("bestie-open-inbox").click();
await expect(page).toHaveURL(/#\/$/);
await expect(page.getByTestId("home-inbox")).toBeVisible();
});
test("captures the live-data Bestie Feed projection", async ({ page }) => {
await page.setViewportSize({ height: 1100, width: 1440 });
await installMockBridge(page);
await page.goto("/#/?view=bestie");
await expect(
page.getByText(LIVE_MENTION, { exact: true }).first(),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({
path: "test-results/bestie-feed/01-live-ranked-cards.png",
});
const card = page
.getByText(LIVE_MENTION, { exact: true })
.first()
.locator("xpath=ancestor::article");
await card.getByRole("button", { name: "Chat with Bestie" }).click();
await expect(page.getByTestId("bestie-chat-panel")).toBeVisible();
await waitForAnimations(page);
await page.screenshot({
path: "test-results/bestie-feed/02-live-item-chat.png",
});
});