mirror of
https://github.com/block/buzz.git
synced 2026-11-05 17:01:35 +00:00
feat(desktop): add persistent Bestie experience (#7223)
## Summary - add a persistent, community-scoped Bestie designation for local managed agents - surface the designated agent in the sidebar, agent library, profile actions, message toolbar, and draggable floating shortcut - bloom the floating shortcut into a lightweight compact composer that reuses the normal DM timeline, reactions, presence, and send behavior - support message handoff with a bounded snapshot and a full Buzz thread link so the agent can retrieve the complete conversation ## UX details - the floating avatar and expanded panel stay above app chrome and drag as one aligned surface - closing the expanded panel returns it to its top-right anchor - each mini-composer opening starts visually fresh while messages sent during that opening remain conversational - the designated Bestie's duplicate DM entry is hidden from the regular DM list - Bestie actions are suppressed inside the mini timeline to avoid recursive handoff ## Reliability and maintainability - preserve existing retention database paths across upgrades - serialize assignment and deletion, clearing matching assignments across community scopes before an agent is removed - fence async conversation resolution against workspace and assignment changes - validate stale assignments against existing local agents before hiding DMs - share lightweight assignment state across agent cards and keep protected-feature behavior out of the shared timeline API ## Testing - `pnpm --dir desktop test` — 5,886 tests passed - `pnpm --dir desktop exec tsc --noEmit` - `pnpm --dir desktop exec biome check ...` - `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warnings` - focused native retention and Bestie assignment/command tests — 34 passed - `VITE_BUZZ_BESTIE=1 pnpm --dir desktop build:e2e` - `VITE_BUZZ_BESTIE=1 pnpm --dir desktop exec playwright test --project=smoke tests/e2e/bestie.spec.ts` - pre-commit and differential pre-push hooks ## Rollout The UI remains gated by the `bestie` build feature. Screenshots covering setup, empty, assigned, floating, and message-handoff states are included in the PR discussion. --------- Signed-off-by: Arjun Mahanti <arjun@squareup.com> Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -9,6 +9,7 @@ type AgentIdentityCardProps = {
|
||||
ariaLabel: string;
|
||||
avatar?: ReactNode;
|
||||
avatarUrl?: string | null;
|
||||
footerAccessory?: ReactNode;
|
||||
dataTestId: string;
|
||||
label: string;
|
||||
/**
|
||||
@@ -28,6 +29,7 @@ export function AgentIdentityCard({
|
||||
avatar,
|
||||
avatarUrl,
|
||||
dataTestId,
|
||||
footerAccessory,
|
||||
label,
|
||||
subtitle,
|
||||
onClick,
|
||||
@@ -74,20 +76,25 @@ export function AgentIdentityCard({
|
||||
<div className="absolute top-3 right-3 z-40">{actions}</div>
|
||||
) : null}
|
||||
|
||||
<div className="pointer-events-none absolute right-3 bottom-3 left-3 z-30 flex min-w-0 flex-col gap-0.5 text-left text-sm leading-5">
|
||||
<span className="min-w-0 truncate font-semibold text-foreground tracking-normal">
|
||||
{label}
|
||||
</span>
|
||||
{subtitle ? (
|
||||
<span className="line-clamp-2 min-w-0 text-xs font-normal text-muted-foreground">
|
||||
{subtitle}
|
||||
<div className="pointer-events-none absolute right-3 bottom-3 left-3 z-30 flex min-w-0 items-end gap-2 text-left text-sm leading-5">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="min-w-0 truncate font-semibold text-foreground tracking-normal">
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
{/* pointer-events-auto: the overlay button above has pointer-events-none
|
||||
on this container, but the status badge itself (a sibling of the button
|
||||
in z-order) needs hover so the restart diff tooltip can fire. */}
|
||||
{statusBadge ? (
|
||||
<div className="pointer-events-auto">{statusBadge}</div>
|
||||
{subtitle ? (
|
||||
<span className="line-clamp-2 min-w-0 text-xs font-normal text-muted-foreground">
|
||||
{subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
{/* pointer-events-auto: the overlay button above has pointer-events-none
|
||||
on this container, but the status badge itself (a sibling of the button
|
||||
in z-order) needs hover so the restart diff tooltip can fire. */}
|
||||
{statusBadge ? (
|
||||
<div className="pointer-events-auto">{statusBadge}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{footerAccessory ? (
|
||||
<div className="shrink-0">{footerAccessory}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,10 @@ import type { AgentPersona, ManagedAgent } from "@/shared/api/types";
|
||||
import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext";
|
||||
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import {
|
||||
ProtectedBestieCardBadge,
|
||||
useProtectedBestiePubkey,
|
||||
} from "@protected-feature-components";
|
||||
import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton";
|
||||
import { AgentIdentityCard } from "./AgentIdentityCard";
|
||||
import { AgentRuntimeAvatarControl } from "./AgentRuntimeAvatarControl";
|
||||
@@ -97,6 +101,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
} = props;
|
||||
|
||||
const isArchived = useIsArchivedPredicate();
|
||||
const bestiePubkey = useProtectedBestiePubkey(agents)?.toLowerCase() ?? null;
|
||||
const { groups, ungrouped, unknown } = React.useMemo(
|
||||
() => buildUnifiedGroups(personas, agents, isArchived),
|
||||
[personas, agents, isArchived],
|
||||
@@ -154,6 +159,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
)}
|
||||
agent={profileAgent}
|
||||
defaultModel={defaultModel}
|
||||
isBestie={profileAgent?.pubkey.toLowerCase() === bestiePubkey}
|
||||
key={group.persona.id}
|
||||
persona={group.persona}
|
||||
restartingAgentPubkey={restartingAgentPubkey}
|
||||
@@ -175,6 +181,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
collapsed={collapsed}
|
||||
defaultModel={defaultModel}
|
||||
groupKey="__unknown__"
|
||||
bestiePubkey={bestiePubkey}
|
||||
label="Unknown agents"
|
||||
restartingAgentPubkey={restartingAgentPubkey}
|
||||
startingAgentPubkey={startingAgentPubkey}
|
||||
@@ -190,6 +197,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
collapsed={collapsed}
|
||||
defaultModel={defaultModel}
|
||||
groupKey="__ungrouped__"
|
||||
bestiePubkey={bestiePubkey}
|
||||
label="Custom agents"
|
||||
restartingAgentPubkey={restartingAgentPubkey}
|
||||
startingAgentPubkey={startingAgentPubkey}
|
||||
@@ -224,6 +232,7 @@ function AgentPersonaCard({
|
||||
actions,
|
||||
agent,
|
||||
defaultModel,
|
||||
isBestie,
|
||||
persona,
|
||||
restartingAgentPubkey,
|
||||
startingAgentPubkey,
|
||||
@@ -240,6 +249,7 @@ function AgentPersonaCard({
|
||||
) => React.ReactNode;
|
||||
agent: ManagedAgent | undefined;
|
||||
defaultModel: string;
|
||||
isBestie: boolean;
|
||||
persona: AgentPersona;
|
||||
restartingAgentPubkey: string | null;
|
||||
startingAgentPubkey: string | null;
|
||||
@@ -316,6 +326,11 @@ function AgentPersonaCard({
|
||||
}
|
||||
avatarUrl={avatarUrl}
|
||||
dataTestId={`persona-agent-row-${persona.id}`}
|
||||
footerAccessory={
|
||||
agent ? (
|
||||
<ProtectedBestieCardBadge agent={agent} isBestie={isBestie} />
|
||||
) : null
|
||||
}
|
||||
label={title}
|
||||
subtitle={subtitle}
|
||||
onClick={() => {
|
||||
@@ -344,6 +359,7 @@ function AgentPersonaCard({
|
||||
|
||||
function StandaloneAgentCard({
|
||||
agent,
|
||||
isBestie,
|
||||
defaultModel,
|
||||
restartingAgentPubkey,
|
||||
startingAgentPubkey,
|
||||
@@ -352,6 +368,7 @@ function StandaloneAgentCard({
|
||||
onStartAgent,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
isBestie: boolean;
|
||||
defaultModel: string;
|
||||
restartingAgentPubkey: string | null;
|
||||
startingAgentPubkey: string | null;
|
||||
@@ -398,6 +415,9 @@ function StandaloneAgentCard({
|
||||
}
|
||||
avatarUrl={profileQuery.data?.avatarUrl}
|
||||
dataTestId={`managed-agent-${agent.pubkey}`}
|
||||
footerAccessory={
|
||||
<ProtectedBestieCardBadge agent={agent} isBestie={isBestie} />
|
||||
}
|
||||
label={title}
|
||||
subtitle={
|
||||
// Definition-less instance: no authored description exists, so fall
|
||||
@@ -450,6 +470,7 @@ function CollapsibleAgentGroup({
|
||||
groupKey,
|
||||
label,
|
||||
agents,
|
||||
bestiePubkey,
|
||||
collapsed,
|
||||
defaultModel,
|
||||
restartingAgentPubkey,
|
||||
@@ -462,6 +483,7 @@ function CollapsibleAgentGroup({
|
||||
groupKey: string;
|
||||
label: string;
|
||||
agents: ManagedAgent[];
|
||||
bestiePubkey: string | null;
|
||||
collapsed: ReadonlySet<string>;
|
||||
defaultModel: string;
|
||||
restartingAgentPubkey: string | null;
|
||||
@@ -496,6 +518,7 @@ function CollapsibleAgentGroup({
|
||||
<StandaloneAgentCard
|
||||
agent={agent}
|
||||
defaultModel={defaultModel}
|
||||
isBestie={agent.pubkey.toLowerCase() === bestiePubkey}
|
||||
key={agent.pubkey}
|
||||
restartingAgentPubkey={restartingAgentPubkey}
|
||||
startingAgentPubkey={startingAgentPubkey}
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
import { isPositiveEmojiParticle } from "@/shared/ui/EmojiBurstProvider";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { ProtectedMessageAction } from "@protected-feature-components";
|
||||
|
||||
const ACTION_BUTTON_CLASS = "h-8 w-8 rounded-full p-0";
|
||||
const ACTION_ICON_CLASS = "!h-4 !w-4";
|
||||
@@ -562,6 +563,8 @@ export const MessageActionBar = React.memo(function MessageActionBar({
|
||||
</Popover>
|
||||
) : null}
|
||||
|
||||
<ProtectedMessageAction channelId={channelId} message={message} />
|
||||
|
||||
{hasReactionAction && quickReactionItems.length > 0 ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -98,6 +98,8 @@ type TimelineMessageListProps = {
|
||||
searchMatchingMessageIds?: Set<string>;
|
||||
/** The current find-in-channel query string. */
|
||||
searchQuery?: string;
|
||||
/** Keep date chips pinned while the timeline scrolls. */
|
||||
stickyDayDividers?: boolean;
|
||||
/** Per-thread unread counts keyed by thread root id. */
|
||||
threadUnreadCounts?: ReadonlyMap<string, number>;
|
||||
/** Content rendered as the first virtual row before channel history. */
|
||||
@@ -154,6 +156,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
searchActiveMessageId = null,
|
||||
searchMatchingMessageIds,
|
||||
searchQuery,
|
||||
stickyDayDividers = true,
|
||||
threadUnreadCounts,
|
||||
unfollowThreadById,
|
||||
leadingContent,
|
||||
@@ -352,7 +355,10 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
key={group.key}
|
||||
>
|
||||
{hideDayDividers || group.headingTimestamp === null ? null : (
|
||||
<DayDivider label={formatDayGroupLabel(group.headingTimestamp)} />
|
||||
<DayDivider
|
||||
label={formatDayGroupLabel(group.headingTimestamp)}
|
||||
sticky={stickyDayDividers}
|
||||
/>
|
||||
)}
|
||||
{group.items.map((item) => (
|
||||
<TimelineRowShell item={item} key={getTimelineItemKey(item)}>
|
||||
|
||||
@@ -31,6 +31,7 @@ export function UserProfileAgentManagementRows({
|
||||
canDeleteAgent,
|
||||
isDeletePending,
|
||||
managedAgent,
|
||||
supplementalAction,
|
||||
onCreateCard,
|
||||
onDeleteAgent,
|
||||
onDuplicateAgent,
|
||||
@@ -41,6 +42,7 @@ export function UserProfileAgentManagementRows({
|
||||
canDeleteAgent: boolean;
|
||||
isDeletePending: boolean;
|
||||
managedAgent?: ManagedAgent;
|
||||
supplementalAction?: React.ReactNode;
|
||||
/** Mint an agent trading card. Present only for owner-managed personas. */
|
||||
onCreateCard?: () => void;
|
||||
onDeleteAgent: () => void;
|
||||
@@ -51,6 +53,7 @@ export function UserProfileAgentManagementRows({
|
||||
!onCreateCard &&
|
||||
!onDuplicateAgent &&
|
||||
!onExportAgent &&
|
||||
!supplementalAction &&
|
||||
!canArchiveAgent &&
|
||||
!canDeleteAgent
|
||||
) {
|
||||
@@ -86,6 +89,7 @@ export function UserProfileAgentManagementRows({
|
||||
testId="user-profile-create-card-row"
|
||||
/>
|
||||
) : null}
|
||||
{supplementalAction}
|
||||
{canArchiveAgent ? (
|
||||
<ProfileArchiveAgentRow archiveActions={archiveActions} />
|
||||
) : null}
|
||||
@@ -100,10 +104,11 @@ export function UserProfileAgentManagementRows({
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileAgentActionRow({
|
||||
export function ProfileAgentActionRow({
|
||||
destructive = false,
|
||||
disabled = false,
|
||||
icon: Icon,
|
||||
iconClassName,
|
||||
label,
|
||||
onClick,
|
||||
testId,
|
||||
@@ -111,6 +116,7 @@ function ProfileAgentActionRow({
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
icon: LucideIcon;
|
||||
iconClassName?: string;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
testId: string;
|
||||
@@ -125,9 +131,10 @@ function ProfileAgentActionRow({
|
||||
>
|
||||
<Icon
|
||||
className={
|
||||
destructive
|
||||
iconClassName ??
|
||||
(destructive
|
||||
? "h-4 w-4 shrink-0 text-destructive"
|
||||
: "h-4 w-4 shrink-0 text-muted-foreground"
|
||||
: "h-4 w-4 shrink-0 text-muted-foreground")
|
||||
}
|
||||
data-slot="profile-action-icon"
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
useProfileActivityFeedScope,
|
||||
} from "@/features/profile/lib/profileActivityFeedScope";
|
||||
import { UserProfileAgentManagementRows } from "@/features/profile/ui/UserProfileAgentManagementRows";
|
||||
import { ProtectedAgentBestieAction } from "@protected-feature-components";
|
||||
import { ProfileInstancesSection } from "@/features/profile/ui/ProfileInstancesSection";
|
||||
import {
|
||||
type ProfileField,
|
||||
@@ -315,6 +316,11 @@ export function ProfileInfoTabContent({
|
||||
onDeleteAgent={onDeleteAgent}
|
||||
onDuplicateAgent={onDuplicateAgent}
|
||||
onExportAgent={onExportAgent}
|
||||
supplementalAction={
|
||||
managedAgent ? (
|
||||
<ProtectedAgentBestieAction agent={managedAgent} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/shared/ui/sidebar";
|
||||
import { useProtectedVisibleDirectMessages } from "@protected-feature-components";
|
||||
|
||||
export function AppSidebar({
|
||||
addCommunityPrefill,
|
||||
@@ -367,10 +368,14 @@ export function AppSidebar({
|
||||
),
|
||||
[channels, sortModeFor],
|
||||
);
|
||||
const directMessages = React.useMemo(
|
||||
const allDirectMessages = React.useMemo(
|
||||
() => channels.filter((channel) => channel.channelType === "dm"),
|
||||
[channels],
|
||||
);
|
||||
const directMessages = useProtectedVisibleDirectMessages(
|
||||
allDirectMessages,
|
||||
currentPubkey,
|
||||
);
|
||||
const isSelectedDirectMessage =
|
||||
selectedView === "channel" &&
|
||||
directMessages.some((channel) => channel.id === selectedChannelId);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from "@/shared/ui/sidebar";
|
||||
import { SidebarMenuLabel } from "@/shared/ui/sidebar-menu-label";
|
||||
import { ProtectedBestieSidebarEntry } from "@protected-feature-components";
|
||||
|
||||
type SidebarSelectedView =
|
||||
| "home"
|
||||
@@ -167,6 +168,7 @@ export function AppSidebarPrimaryMenu({
|
||||
<SidebarMenuLabel>Agents</SidebarMenuLabel>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<ProtectedBestieSidebarEntry />
|
||||
<FeatureGate feature="workflows">
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
|
||||
Reference in New Issue
Block a user