From b191f35f1d355dc9352fa247b6c641e126be2e11 Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Tue, 25 Aug 2026 11:49:56 -0400 Subject: [PATCH] feat(desktop): prototype Bestie Feed Co-authored-by: Arjun Mahanti Signed-off-by: Arjun Mahanti --- desktop/playwright.config.ts | 9 +- desktop/src/app/routes/index.tsx | 7 + .../features/bestie/lib/bestieFeed.test.mjs | 112 ++++++++ desktop/src/features/bestie/lib/bestieFeed.ts | 72 +++++ .../src/features/bestie/ui/BestieFeedCard.tsx | 217 +++++++++++++++ .../features/bestie/ui/BestieFeedHeader.tsx | 137 ++++++++++ .../features/bestie/ui/BestieFeedPanel.tsx | 170 ++++++++++++ .../src/features/bestie/ui/BestieFeedView.tsx | 250 ++++++++++++++++++ desktop/src/features/home/ui/HomeScreen.tsx | 61 +++-- .../src/features/home/ui/InboxListPane.tsx | 25 +- desktop/tests/e2e/bestie-feed.spec.ts | 107 ++++++++ 11 files changed, 1142 insertions(+), 25 deletions(-) create mode 100644 desktop/src/features/bestie/lib/bestieFeed.test.mjs create mode 100644 desktop/src/features/bestie/lib/bestieFeed.ts create mode 100644 desktop/src/features/bestie/ui/BestieFeedCard.tsx create mode 100644 desktop/src/features/bestie/ui/BestieFeedHeader.tsx create mode 100644 desktop/src/features/bestie/ui/BestieFeedPanel.tsx create mode 100644 desktop/src/features/bestie/ui/BestieFeedView.tsx create mode 100644 desktop/tests/e2e/bestie-feed.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index be15c75587..8bc39d644c 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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}`, }, }); diff --git a/desktop/src/app/routes/index.tsx b/desktop/src/app/routes/index.tsx index 82deb4ac73..241675e2be 100644 --- a/desktop/src/app/routes/index.tsx +++ b/desktop/src/app/routes/index.tsx @@ -15,6 +15,7 @@ type HomeRouteSearch = { profile?: string; profileTab?: string; profileView?: string; + view?: "bestie"; }; function validateHomeSearch(search: Record): HomeRouteSearch { @@ -35,6 +36,7 @@ function validateHomeSearch(search: Record): 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 ( void navigate({ search: {}, to: "/" })} onOpenContext={(channelId, messageId, threadRootId) => { void goChannel(channelId, { messageId, threadRootId }); }} + view={view} /> ); } diff --git a/desktop/src/features/bestie/lib/bestieFeed.test.mjs b/desktop/src/features/bestie/lib/bestieFeed.test.mjs new file mode 100644 index 0000000000..760b99b67e --- /dev/null +++ b/desktop/src/features/bestie/lib/bestieFeed.test.mjs @@ -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", + }); +}); diff --git a/desktop/src/features/bestie/lib/bestieFeed.ts b/desktop/src/features/bestie/lib/bestieFeed.ts new file mode 100644 index 0000000000..b7c4ef0b19 --- /dev/null +++ b/desktop/src/features/bestie/lib/bestieFeed.ts @@ -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>, + 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", + }; +} diff --git a/desktop/src/features/bestie/ui/BestieFeedCard.tsx b/desktop/src/features/bestie/ui/BestieFeedCard.tsx new file mode 100644 index 0000000000..52c7ae6bed --- /dev/null +++ b/desktop/src/features/bestie/ui/BestieFeedCard.tsx @@ -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(); + 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 ( +
+
+ {projectItem ? ( + + ) : item.categories.includes("agent_activity") ? ( + + ) : ( + + )} + + {bestieItemSourceLabel(item)} + + · + {item.categoryLabel} + {item.unreadCount > 0 ? · {item.unreadCount} unread : null} +
+ {content.length > 0 ? ( +
+ {content.map((message) => ( +
+ {message} +
+ ))} +
+ ) : ( +

+ This Buzz event has no text content. +

+ )} +
+ ); +} + +function IconAction({ + children, + label, + onClick, + testId, +}: { + children: React.ReactNode; + label: string; + onClick: () => void; + testId: string; +}) { + return ( + + + + + {label} + + ); +} + +export function BestieFeedCard({ + item, + onChat, + onReply, + onSnooze, +}: BestieFeedCardProps) { + const headingId = `bestie-card-heading-${item.id}`; + + return ( +
+
+ +
+ {item.categories.map((category) => ( + + {category === "needs_action" + ? "Needs action" + : category === "agent_activity" + ? "Agent update" + : category === "mention" + ? "Mention" + : "Activity"} + + ))} +
+
+ +
+ {item.avatarUrl ? ( + + ) : ( + + )} +
+

{item.senderLabel}

+

+ {bestieItemSourceLabel(item)} +

+
+
+ +

+ {item.subject} +

+

+ {item.preview} +

+ +
+ +
+ +
+
+ + + + + + +
+ +
+
+ ); +} diff --git a/desktop/src/features/bestie/ui/BestieFeedHeader.tsx b/desktop/src/features/bestie/ui/BestieFeedHeader.tsx new file mode 100644 index 0000000000..4f808b62da --- /dev/null +++ b/desktop/src/features/bestie/ui/BestieFeedHeader.tsx @@ -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 = { + 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 ( + <> +
+ +

+ {greetingForHour(new Date().getHours())}. Here’s what’s happening in + your Buzz. +

+
+ + Live from your Home feed + + + Prototype ranking · no generated summaries + +
+
+ +
+
+ {(Object.keys(FILTER_LABELS) as BestieFeedFilter[]).map( + (nextFilter) => ( + + ), + )} + +
+
+
+ + onSearchChange(event.target.value)} + placeholder="Search live feed" + value={query} + /> +
+ + +
+
+ + ); +} diff --git a/desktop/src/features/bestie/ui/BestieFeedPanel.tsx b/desktop/src/features/bestie/ui/BestieFeedPanel.tsx new file mode 100644 index 0000000000..ca44b68d0b --- /dev/null +++ b/desktop/src/features/bestie/ui/BestieFeedPanel.tsx @@ -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([]); + + 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 ( + + + +
+ {isChat ? : null} + + {isChat + ? "Bestie · About this item" + : `Reply in ${bestieItemSourceLabel(item)}`} + +
+ + {isChat + ? "The exact Buzz item is pinned below. Agent chat is not connected yet." + : "This panel shows the real source conversation from Buzz."} + +
+ + {isChat + ? "Prototype chat · no agent response" + : "Prototype composer · nothing is sent"} + + {item.item.channelId ? ( + + ) : null} +
+
+ +
+

+ Pinned Buzz context +

+

{item.subject}

+

+ {item.preview} +

+
+ +
+ {messages.length > 0 ? ( + messages.map((message) => ( +
+ {message.content} +
+ )) + ) : ( +

+ Bestie chat is not connected in this prototype. Add a question + here to test the interaction; it stays local and receives no + simulated answer. +

+ )} +
+ +
+