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:
Arjun Mahanti
2026-09-02 17:56:17 -04:00
committed by GitHub
parent 6f6093243e
commit ac5a18697c
43 changed files with 3279 additions and 41 deletions

View File

@@ -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>

View File

@@ -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}

View File

@@ -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"

View File

@@ -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)}>

View File

@@ -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"
/>

View File

@@ -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>
);

View File

@@ -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);

View File

@@ -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