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

@@ -0,0 +1,25 @@
import { Star } from "lucide-react";
import type { ManagedAgent } from "@/shared/api/types";
export function BestieCardBadge({
agent,
isBestie,
}: {
agent: ManagedAgent;
isBestie: boolean;
}) {
if (!isBestie) return null;
return (
<span
aria-label={`${agent.name} is your Bestie`}
className="inline-flex shrink-0 items-center justify-center text-foreground opacity-50"
data-testid={`bestie-card-badge-${agent.pubkey}`}
role="img"
title="Bestie"
>
<Star className="h-4 w-4 fill-current" />
</span>
);
}

View File

@@ -0,0 +1,212 @@
import * as React from "react";
import { LayoutGroup, motion } from "motion/react";
import { createPortal } from "react-dom";
import { Bloom } from "./BloomMenu";
import { BestiePopover, BestieTriggerVisual } from "./BestiePopover";
import { useBestie } from "./useBestie";
type Point = { x: number; y: number };
type Placement = {
anchor: "start" | "end";
direction: "top" | "bottom";
};
type DragBounds = {
maxX: number;
maxY: number;
minX: number;
minY: number;
};
const AVATAR_SIZE = 48;
const EDGE_INSET = 16;
const BESTIE_AVATAR_LAYOUT_ID = "bestie-floating-bloom-avatar";
function clampPoint(point: Point): Point {
return {
x: Math.min(
Math.max(EDGE_INSET, point.x),
Math.max(EDGE_INSET, window.innerWidth - AVATAR_SIZE - EDGE_INSET),
),
y: Math.min(
Math.max(EDGE_INSET, point.y),
Math.max(EDGE_INSET, window.innerHeight - AVATAR_SIZE - EDGE_INSET),
),
};
}
function initialPoint(): Point {
return clampPoint({ x: window.innerWidth - AVATAR_SIZE - 20, y: 52 });
}
export function BestieGlobalOverlay() {
const bestie = useBestie();
const [open, setOpen] = React.useState(false);
const [placement, setPlacement] = React.useState<Placement | null>(null);
const [shareAvatarLayout, setShareAvatarLayout] = React.useState(true);
const [dragging, setDragging] = React.useState(false);
const [position, setPosition] = React.useState<Point>(initialPoint);
const dragRef = React.useRef<
| {
bounds: DragBounds;
moved: boolean;
open: boolean;
origin: Point;
pointerId: number;
start: Point;
}
| undefined
>(undefined);
const suppressClickRef = React.useRef(false);
React.useEffect(() => {
const handleResize = () => setPosition((current) => clampPoint(current));
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const currentPlacement: Placement = {
direction:
position.y > (window.innerHeight - AVATAR_SIZE) / 2 ? "top" : "bottom",
anchor:
position.x > (window.innerWidth - AVATAR_SIZE) / 2 ? "end" : "start",
};
const activePlacement = open && placement ? placement : currentPlacement;
const updateOpen = (nextOpen: boolean) => {
setShareAvatarLayout(nextOpen);
if (nextOpen) setPlacement(currentPlacement);
setOpen(nextOpen);
};
return createPortal(
<div
className="pointer-events-auto fixed z-[200]"
data-testid="bestie-floating-avatar"
onPointerCancel={(event) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
dragRef.current = undefined;
setDragging(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
}}
onPointerDown={(event) => {
if (event.button !== 0) return;
const target = event.target as HTMLElement;
if (
open &&
(!target.closest("[data-bestie-drag-handle]") ||
target.closest("button, input, textarea, a"))
) {
return;
}
const surface = open
? event.currentTarget
.querySelector('[data-testid="bestie-bloom-content"]')
?.getBoundingClientRect()
: event.currentTarget.getBoundingClientRect();
if (!surface) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
setShareAvatarLayout(false);
dragRef.current = {
bounds: {
maxX: window.innerWidth - EDGE_INSET - surface.right,
maxY: window.innerHeight - EDGE_INSET - surface.bottom,
minX: EDGE_INSET - surface.left,
minY: EDGE_INSET - surface.top,
},
open,
pointerId: event.pointerId,
origin: position,
start: { x: event.clientX, y: event.clientY },
moved: false,
};
}}
onPointerMove={(event) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const rawX = event.clientX - drag.start.x;
const rawY = event.clientY - drag.start.y;
const dx = Math.min(drag.bounds.maxX, Math.max(drag.bounds.minX, rawX));
const dy = Math.min(drag.bounds.maxY, Math.max(drag.bounds.minY, rawY));
if (Math.hypot(dx, dy) > 4) drag.moved = true;
setPosition(
clampPoint({ x: drag.origin.x + dx, y: drag.origin.y + dy }),
);
}}
onPointerUp={(event) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
if (!drag.open) suppressClickRef.current = drag.moved;
dragRef.current = undefined;
setDragging(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (!drag.open && !drag.moved) updateOpen(true);
}}
style={{
left: 0,
top: 0,
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
willChange: "transform",
}}
>
<LayoutGroup id="bestie-floating-bloom">
<Bloom.Root
anchor={activePlacement.anchor}
direction={activePlacement.direction}
onOpenChange={(nextOpen) => {
if (nextOpen && suppressClickRef.current) {
suppressClickRef.current = false;
return;
}
updateOpen(nextOpen);
}}
open={open}
>
<Bloom.Container
buttonSize={AVATAR_SIZE}
className="border border-border/70 bg-popover text-popover-foreground ring-1 ring-foreground/5"
edgeDraggable
menuRadius={20}
menuWidth={320}
motionDisabled={dragging}
onMorphAnimationComplete={(isOpen) => {
if (isOpen) setShareAvatarLayout(false);
}}
>
<Bloom.Trigger
ariaLabel="Open Bestie"
className="flex h-12 w-12 touch-none select-none items-center justify-center rounded-full border-0 bg-transparent p-0 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring [&_img]:pointer-events-none"
>
<motion.div
key={shareAvatarLayout ? "shared-avatar" : "static-avatar"}
layoutId={
shareAvatarLayout ? BESTIE_AVATAR_LAYOUT_ID : undefined
}
>
<BestieTriggerVisual
agent={bestie.assignedAgent}
imageDraggable={false}
/>
</motion.div>
</Bloom.Trigger>
<Bloom.Content ariaLabel="Bestie" className="p-4">
<BestiePopover
avatarLayoutId={
shareAvatarLayout ? BESTIE_AVATAR_LAYOUT_ID : undefined
}
onRequestClose={() => updateOpen(false)}
/>
</Bloom.Content>
</Bloom.Container>
</Bloom.Root>
</LayoutGroup>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,46 @@
import type { TimelineMessage } from "@/features/messages/types";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { BestiePopover, BestieTriggerVisual } from "./BestiePopover";
import { useBestie } from "./useBestie";
export function BestieMessageAction({
channelId,
message,
}: {
channelId?: string | null;
message: TimelineMessage;
}) {
const bestie = useBestie();
const [open, setOpen] = React.useState(false);
return (
<Popover onOpenChange={setOpen} open={open}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
aria-label="Ask Bestie about this message"
className="h-8 w-8 rounded-full p-0"
data-testid={`bestie-message-${message.id}`}
size="sm"
type="button"
variant="ghost"
>
<BestieTriggerVisual agent={bestie.assignedAgent} compact />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Ask Bestie</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-80" side="top" sideOffset={10}>
<BestiePopover
contextChannelId={channelId}
contextMessage={message}
onRequestClose={() => setOpen(false)}
/>
</PopoverContent>
</Popover>
);
}
import * as React from "react";

View File

@@ -0,0 +1,496 @@
import { ArrowUp, Plus, X } from "lucide-react";
import { motion } from "motion/react";
import * as React from "react";
import { toast } from "sonner";
import {
useChannelMessagesQuery,
useChannelSubscription,
useSendMessageMutation,
useToggleReactionMutation,
} from "@/features/messages/hooks";
import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration";
import type { TimelineMessage } from "@/features/messages/types";
import { TimelineMessageList } from "@/features/messages/ui/TimelineMessageList";
import { ProtectedMessageActionsBoundary } from "@protected-feature-components";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import { useProfileQuery } from "@/features/profile/hooks";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { Channel, ManagedAgent, PresenceStatus } from "@/shared/api/types";
import {
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
} from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import { Textarea } from "@/shared/ui/textarea";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { buildBestieMessageContext } from "./bestieMessageContext";
import { useBestie } from "./useBestie";
export function BestieTriggerVisual({
agent,
className,
compact = false,
imageDraggable,
}: {
agent: ManagedAgent | null;
className?: string;
compact?: boolean;
imageDraggable?: boolean;
}) {
if (agent) {
return (
<UserAvatar
avatarUrl={agent.avatarUrl}
className={cn(compact ? "h-6 w-6" : "h-10 w-10", className)}
displayName={agent.name}
fallbackDelayMs={0}
imageDraggable={imageDraggable}
size={compact ? "sm" : "md"}
testId="bestie-trigger-avatar"
/>
);
}
return (
<Plus
aria-hidden="true"
className={cn(compact ? "h-4 w-4" : "h-5 w-5", className)}
data-testid="bestie-empty-mark"
/>
);
}
export function BestieAgentLockup({
agent,
avatarLayoutId,
presenceStatus,
compact = false,
}: {
agent: ManagedAgent;
avatarLayoutId?: string;
presenceStatus: PresenceStatus;
compact?: boolean;
}) {
return (
<div className="flex min-w-0 items-center gap-2.5">
<motion.div
aria-hidden="true"
className="relative shrink-0"
data-testid="bestie-agent-avatar"
layoutId={avatarLayoutId}
>
<UserAvatar
avatarUrl={agent.avatarUrl}
className={compact ? "h-5 w-5" : "h-8 w-8"}
displayName={agent.name}
fallbackDelayMs={0}
size={compact ? "xs" : "sm"}
/>
<span
className={cn(
"absolute flex items-center justify-center rounded-full",
compact
? "-bottom-0.5 -right-0.5 h-2.5 w-2.5 bg-sidebar"
: "-bottom-0.5 -right-0.5 h-3.5 w-3.5 bg-popover",
)}
>
<PresenceDot
className={compact ? "h-1.5 w-1.5" : "h-2 w-2"}
data-testid="bestie-activity-dot"
status={presenceStatus}
/>
</span>
</motion.div>
<span className="min-w-0 truncate text-sm font-medium">{agent.name}</span>
<span className="sr-only">{presenceStatus}</span>
</div>
);
}
function EmptyBestie() {
return (
<div className="flex min-h-32 flex-col items-center justify-center gap-3 px-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Plus aria-hidden="true" className="h-5 w-5" />
</div>
<div>
<h2 className="text-sm font-semibold">Choose a Bestie</h2>
<p className="mt-1 text-xs text-muted-foreground">
Open one of your local agents and turn on Bestie.
</p>
</div>
</div>
);
}
function BestieConversationTranscript({
channel,
currentPubkey,
messages,
onToggleReaction,
profiles,
}: {
channel: Channel;
currentPubkey: string | undefined;
messages: TimelineMessage[];
onToggleReaction: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
profiles: UserProfileLookup;
}) {
const transcriptRef = React.useRef<HTMLDivElement>(null);
const latestMessageKey = messages.at(-1)?.renderKey ?? messages.at(-1)?.id;
const mainTimelineEntries = React.useMemo(
() => buildMainTimelineEntries(messages, undefined, undefined, profiles),
[messages, profiles],
);
useRenderScopedReactionHydration({
activeChannel: channel,
mainTimelineEntries,
threadHeadMessage: null,
threadMessages: [],
});
React.useLayoutEffect(() => {
if (!latestMessageKey) return;
const transcript = transcriptRef.current;
if (!transcript) return;
transcript.scrollTop = transcript.scrollHeight;
}, [latestMessageKey]);
return (
<div
aria-live="polite"
className="h-full min-h-0 max-h-48 overflow-y-auto"
data-bestie-channel-id={channel.id}
data-bestie-channel-name={channel.name}
data-testid="bestie-mini-transcript"
ref={transcriptRef}
>
<ProtectedMessageActionsBoundary>
<TimelineMessageList
channelId={channel.id}
channelName={channel.name}
channelType={channel.channelType}
currentPubkey={currentPubkey}
mainEntries={mainTimelineEntries}
messages={messages}
onToggleReaction={onToggleReaction}
profiles={profiles}
stickyDayDividers={false}
/>
</ProtectedMessageActionsBoundary>
</div>
);
}
export function BestiePopover({
avatarLayoutId,
contextChannelId,
contextMessage,
onRequestClose,
}: {
avatarLayoutId?: string;
contextChannelId?: string | null;
contextMessage?: TimelineMessage;
onRequestClose?: () => void;
}) {
const bestie = useBestie();
const [draft, setDraft] = React.useState("");
const [contextSent, setContextSent] = React.useState(false);
const [conversationChannel, setConversationChannel] =
React.useState<Channel | null>(null);
const [sessionBoundary, setSessionBoundary] = React.useState<{
baselineMessageIds: ReadonlySet<string>;
firstMessageCreatedAt: number;
} | null>(null);
const identityQuery = useIdentityQuery();
const profileQuery = useProfileQuery();
const conversationQuery = useChannelMessagesQuery(conversationChannel);
useChannelSubscription(conversationChannel);
const sendMutation = useSendMessageMutation(
conversationChannel,
identityQuery.data,
);
const toggleReactionMutation = useToggleReactionMutation();
const toggleReactionMutateRef = React.useRef(
toggleReactionMutation.mutateAsync,
);
toggleReactionMutateRef.current = toggleReactionMutation.mutateAsync;
const agent = bestie.assignedAgent;
const assignedAgentPubkey = agent?.pubkey;
const conversationPromiseRef = React.useRef<Promise<Channel> | null>(null);
const resolveConversationForOpen = React.useEffectEvent(() =>
bestie.resolveConversation(),
);
React.useEffect(() => {
setConversationChannel(null);
conversationPromiseRef.current = null;
if (!assignedAgentPubkey) return;
let cancelled = false;
const pending = resolveConversationForOpen();
conversationPromiseRef.current = pending;
void pending
.then((channel) => {
if (!cancelled) setConversationChannel(channel);
})
.catch((error) => {
console.warn("Couldn’t load the Bestie conversation", error);
})
.finally(() => {
if (conversationPromiseRef.current === pending) {
conversationPromiseRef.current = null;
}
});
return () => {
cancelled = true;
};
}, [assignedAgentPubkey]);
const currentPubkey = identityQuery.data?.pubkey;
const currentProfile = profileQuery.data;
const conversationProfiles = React.useMemo<UserProfileLookup>(() => {
if (!agent) return {};
const profiles: UserProfileLookup = {
[normalizePubkey(agent.pubkey)]: {
avatarUrl: agent.avatarUrl,
displayName: agent.name,
isAgent: true,
name: agent.name,
nip05Handle: null,
ownerPubkey: null,
},
};
if (currentPubkey) {
profiles[normalizePubkey(currentPubkey)] = {
avatarUrl: currentProfile?.avatarUrl ?? null,
displayName: "You",
isAgent: false,
name: null,
nip05Handle: currentProfile?.nip05Handle ?? null,
ownerPubkey: null,
};
}
return profiles;
}, [
agent,
currentProfile?.avatarUrl,
currentProfile?.nip05Handle,
currentPubkey,
]);
const contextEnvelope = React.useMemo(
() => buildBestieMessageContext(contextChannelId, contextMessage),
[contextChannelId, contextMessage],
);
const allConversationMessages = React.useMemo(() => {
if (!conversationChannel) return [];
return formatTimelineMessages(
conversationQuery.data ?? [],
conversationChannel,
currentPubkey,
currentProfile?.avatarUrl ?? null,
conversationProfiles,
)
.filter(
(message) =>
(message.kind === KIND_STREAM_MESSAGE ||
message.kind === KIND_STREAM_MESSAGE_V2) &&
!message.parentId,
)
.map((message) => {
if (!contextEnvelope || !message.body.startsWith(contextEnvelope)) {
return message;
}
return {
...message,
body: message.body.slice(contextEnvelope.length).trim(),
};
})
.filter((message) => message.body.length > 0);
}, [
contextEnvelope,
conversationChannel,
conversationProfiles,
conversationQuery.data,
currentProfile?.avatarUrl,
currentPubkey,
]);
const conversationMessages = React.useMemo(() => {
if (!sessionBoundary) return [];
return allConversationMessages
.filter(
(message) =>
message.createdAt >= sessionBoundary.firstMessageCreatedAt &&
!sessionBoundary.baselineMessageIds.has(message.id),
)
.slice(-12);
}, [allConversationMessages, sessionBoundary]);
const handleToggleReaction = React.useCallback(
async (message: TimelineMessage, emoji: string, remove: boolean) => {
await toggleReactionMutateRef.current({
emoji,
eventId: message.id,
remove,
});
},
[],
);
if (bestie.isLoading) {
return <p className="text-sm text-muted-foreground">Loading Bestie…</p>;
}
if (!agent) return <EmptyBestie />;
const presenceStatus = bestie.presenceStatus ?? "offline";
const sendMessage = () => {
const trimmedDraft = draft.trim();
if (!trimmedDraft || bestie.isOpening || sendMutation.isPending) return;
void (async () => {
const baselineMessageIds = new Set(
allConversationMessages.map((message) => message.id),
);
const startResult = bestie.ensureAgentRunning().then(
() => ({ error: null }),
(error: unknown) => ({ error }),
);
const channel =
conversationChannel ??
(await (conversationPromiseRef.current ??
bestie.resolveConversation()));
setConversationChannel(channel);
const sentMessage = await sendMutation.mutateAsync({
content:
contextEnvelope && !contextSent
? `${contextEnvelope}\n\n${trimmedDraft}`
: trimmedDraft,
targetChannel: channel,
});
setSessionBoundary(
(current) =>
current ?? {
baselineMessageIds,
firstMessageCreatedAt: sentMessage.created_at,
},
);
setContextSent(true);
setDraft("");
const { error: startError } = await startResult;
if (startError) throw startError;
})().catch((error) => {
toast.error(
error instanceof Error ? error.message : "Couldn’t message Bestie",
);
});
};
return (
<div className="flex max-h-[min(32rem,var(--radix-popover-content-available-height,calc(100vh-2rem)))] flex-col gap-4">
<div
className="flex shrink-0 touch-none select-none items-center gap-3 cursor-grab active:cursor-grabbing"
data-bestie-drag-handle
>
<BestieAgentLockup
agent={agent}
avatarLayoutId={avatarLayoutId}
presenceStatus={presenceStatus}
/>
<div className="flex-1" />
<Button
aria-label="Close Bestie"
onClick={onRequestClose}
size="icon-xs"
variant="ghost"
>
<X />
</Button>
</div>
{conversationMessages.length > 0 && conversationChannel ? (
<div className="min-h-0 max-h-48 overflow-hidden">
<BestieConversationTranscript
channel={conversationChannel}
currentPubkey={currentPubkey}
messages={conversationMessages}
onToggleReaction={handleToggleReaction}
profiles={conversationProfiles}
/>
</div>
) : null}
{contextMessage && !contextSent ? (
<div
className="shrink-0 space-y-2"
data-testid="bestie-message-context"
>
<div
className="max-h-24 max-w-[75%] overflow-hidden rounded-xl border border-border/70 bg-muted/45 p-2.5 shadow-xs"
data-testid="bestie-message-snapshot"
>
<div className="flex min-w-0 items-center gap-2">
<UserAvatar
avatarUrl={contextMessage.avatarUrl ?? null}
className="h-5 w-5"
displayName={contextMessage.author}
fallbackDelayMs={0}
size="xs"
/>
<span className="truncate text-xs font-semibold">
{contextMessage.author}
</span>
</div>
<p className="mt-1.5 whitespace-pre-wrap break-words text-xs leading-4 text-foreground/80">
{contextMessage.body}
</p>
</div>
<div className="w-fit rounded-2xl bg-muted px-3 py-2 text-sm">
How can I help?
</div>
</div>
) : null}
<div className="relative shrink-0">
<Textarea
aria-label={`Message ${agent.name}`}
className="min-h-24 resize-none rounded-2xl pb-11"
data-bloom-autofocus
data-testid="bestie-composer"
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (
event.key !== "Enter" ||
event.shiftKey ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.nativeEvent.isComposing
) {
return;
}
event.preventDefault();
sendMessage();
}}
placeholder={`Message ${agent.name}`}
value={draft}
/>
<Button
aria-label="Send in Bestie conversation"
className="absolute bottom-2 right-2 rounded-full"
disabled={!draft.trim() || bestie.isOpening || sendMutation.isPending}
onClick={sendMessage}
size="icon"
>
<ArrowUp />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import * as React from "react";
import { Star } from "lucide-react";
import { toast } from "sonner";
import { ProfileAgentActionRow } from "@/features/profile/ui/UserProfileAgentManagementRows";
import type { ManagedAgent } from "@/shared/api/types";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { useBestie } from "./useBestie";
export function BestieProfileAction({ agent }: { agent: ManagedAgent }) {
const bestie = useBestie();
const [confirmOpen, setConfirmOpen] = React.useState(false);
if (agent.backend.type !== "local") return null;
const isBestie =
bestie.assignment?.agentPubkey.toLowerCase() === agent.pubkey.toLowerCase();
const isPending = bestie.isAssigning;
const handleClick = () => {
if (!isBestie) {
setConfirmOpen(true);
return;
}
void bestie.clearAssignment().catch((error) => {
toast.error(
error instanceof Error ? error.message : "Couldn’t update Bestie",
);
});
};
return (
<>
<ProfileAgentActionRow
disabled={bestie.isLoading || isPending}
icon={Star}
iconClassName={
isBestie ? "h-4 w-4 shrink-0 fill-current text-foreground" : undefined
}
label={isBestie ? "Remove Bestie" : "Make Bestie"}
onClick={handleClick}
testId="user-profile-bestie-action"
/>
<AlertDialog onOpenChange={setConfirmOpen} open={confirmOpen}>
<AlertDialogContent data-testid="bestie-confirm-dialog">
<AlertDialogHeader>
<AlertDialogTitle>Make {agent.name} your Bestie?</AlertDialogTitle>
<AlertDialogDescription>
{bestie.assignedAgent && !isBestie
? `${agent.name} will replace ${bestie.assignedAgent.name} in the floating shortcut and message actions.`
: `${agent.name} will appear in the floating shortcut and message actions.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isPending}
onClick={(event) => {
event.preventDefault();
void bestie
.assignAgent(agent)
.then(() => setConfirmOpen(false))
.catch((error) => {
toast.error(
error instanceof Error
? error.message
: "Couldn’t update Bestie",
);
});
}}
>
{isPending ? "Saving…" : "Make Bestie"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,50 @@
import { Plus } from "lucide-react";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { SidebarMenuButton, SidebarMenuItem } from "@/shared/ui/sidebar";
import { BestieAgentLockup } from "./BestiePopover";
import { useBestie } from "./useBestie";
export function BestieSidebarEntry() {
const bestie = useBestie();
const { goAgents } = useAppNavigation();
const handleClick = () => {
if (!bestie.assignedAgent) {
void goAgents();
return;
}
void bestie.openConversation().catch((error) => {
toast.error(
error instanceof Error
? error.message
: "Couldn’t open Bestie conversation",
);
});
};
return (
<SidebarMenuItem data-testid="bestie-sidebar-entry">
<SidebarMenuButton
disabled={bestie.isOpening}
onClick={handleClick}
tooltip="Bestie"
type="button"
>
{bestie.assignedAgent ? (
<BestieAgentLockup
agent={bestie.assignedAgent}
compact
presenceStatus={bestie.presenceStatus ?? "offline"}
/>
) : (
<>
<Plus className="h-4 w-4" />
<span>Bestie</span>
</>
)}
</SidebarMenuButton>
</SidebarMenuItem>
);
}

View File

@@ -0,0 +1,362 @@
/**
* Bestie-scoped adaptation of Josh Puckett's MIT-licensed Bloom menu.
* Source: https://github.com/joshpuckett/bloom/tree/8fa80a0f136b3f687c1641a09ff8c97aa8e514ba/packages/bloom
*
* The public package could not be pulled through the workspace's approved npm
* proxy, so this keeps the small Root/Container/Trigger/Content surface we use
* and points it at the repo's existing Motion runtime.
*/
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
type Direction = "top" | "bottom";
type Anchor = "start" | "center" | "end";
type OpenChangeOptions = { restoreFocus?: boolean };
type BloomContextValue = {
anchor: Anchor;
contentId: string;
contentRef: React.RefObject<HTMLDivElement | null>;
direction: Direction;
open: boolean;
setOpen: (open: boolean, options?: OpenChangeOptions) => void;
triggerRef: React.RefObject<HTMLButtonElement | null>;
};
const BloomContext = React.createContext<BloomContextValue | null>(null);
function useBloomContext() {
const context = React.useContext(BloomContext);
if (!context) throw new Error("Bloom components require Bloom.Root");
return context;
}
function Root({
anchor = "start",
children,
direction = "top",
onOpenChange,
open,
}: {
anchor?: Anchor;
children: React.ReactNode;
direction?: Direction;
onOpenChange: (open: boolean) => void;
open: boolean;
}) {
const triggerRef = React.useRef<HTMLButtonElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const contentId = React.useId();
const previousOpenRef = React.useRef(open);
const restoreFocusOnCloseRef = React.useRef(true);
const setOpen = React.useCallback(
(nextOpen: boolean, options?: OpenChangeOptions) => {
if (nextOpen) {
restoreFocusOnCloseRef.current = true;
} else {
restoreFocusOnCloseRef.current = options?.restoreFocus ?? true;
}
onOpenChange(nextOpen);
},
[onOpenChange],
);
React.useEffect(() => {
const wasOpen = previousOpenRef.current;
previousOpenRef.current = open;
if (wasOpen === open) return;
const frame = requestAnimationFrame(() => {
if (open) {
const preferredTarget = contentRef.current?.querySelector<HTMLElement>(
"[data-bloom-autofocus]",
);
const fallbackTarget = contentRef.current?.querySelector<HTMLElement>(
"button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href]",
);
(preferredTarget ?? fallbackTarget ?? contentRef.current)?.focus();
} else if (restoreFocusOnCloseRef.current) {
triggerRef.current?.focus();
}
});
return () => cancelAnimationFrame(frame);
}, [open]);
React.useEffect(() => {
if (!open) return;
const closeOnOutsidePress = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node;
if (
!triggerRef.current?.contains(target) &&
!contentRef.current?.contains(target)
) {
setOpen(false, { restoreFocus: false });
}
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
setOpen(false);
};
document.addEventListener("mousedown", closeOnOutsidePress);
document.addEventListener("touchstart", closeOnOutsidePress);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("mousedown", closeOnOutsidePress);
document.removeEventListener("touchstart", closeOnOutsidePress);
document.removeEventListener("keydown", closeOnEscape);
};
}, [open, setOpen]);
const value = React.useMemo(
() => ({
anchor,
contentId,
contentRef,
direction,
open,
setOpen,
triggerRef,
}),
[anchor, contentId, direction, open, setOpen],
);
return (
<BloomContext.Provider value={value}>{children}</BloomContext.Provider>
);
}
function Container({
buttonSize = 48,
children,
className,
edgeDraggable = false,
menuRadius = 20,
menuWidth = 320,
motionDisabled = false,
onMorphAnimationComplete,
}: {
buttonSize?: number;
children: React.ReactNode;
className?: string;
edgeDraggable?: boolean;
menuRadius?: number;
menuWidth?: number;
motionDisabled?: boolean;
onMorphAnimationComplete?: (open: boolean) => void;
}) {
const { anchor, direction, open, setOpen } = useBloomContext();
const reduceMotion = useReducedMotion();
const measureRef = React.useRef<HTMLDivElement>(null);
const [measuredHeight, setMeasuredHeight] = React.useState(buttonSize);
React.useLayoutEffect(() => {
if (!open || !measureRef.current) return;
const measure = () => {
if (measureRef.current) {
setMeasuredHeight(measureRef.current.offsetHeight);
}
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(measureRef.current);
return () => observer.disconnect();
}, [open]);
const horizontalOffset =
anchor === "start"
? 0
: -(menuWidth - buttonSize) * (anchor === "center" ? 0.5 : 1);
const verticalOffset =
direction === "top" ? -buttonSize * 0.75 : buttonSize * 0.75;
const openTransform = `translate3d(${horizontalOffset}px, ${verticalOffset}px, 0) scale(1)`;
return (
<div className="relative h-12 w-12">
<motion.div
animate={{
borderRadius: open ? menuRadius : buttonSize / 2,
boxShadow: open
? "0 20px 25px -5px rgb(0 0 0 / 0.12), 0 8px 10px -6px rgb(0 0 0 / 0.1)"
: "0 10px 15px -3px rgb(0 0 0 / 0.12), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
height: open ? measuredHeight : buttonSize,
transform: open ? openTransform : "translate3d(0, 0, 0) scale(1)",
width: open ? menuWidth : buttonSize,
}}
className={className}
data-testid="bestie-bloom-container"
initial={false}
onAnimationComplete={() => onMorphAnimationComplete?.(open)}
onClick={(event) => {
if (open) return;
event.preventDefault();
setOpen(true);
}}
style={{
...(direction === "top"
? { bottom: 0, left: 0 }
: { left: 0, top: 0 }),
cursor: open ? "default" : "pointer",
overflow: "hidden",
position: "absolute",
transformOrigin: `${
anchor === "start" ? "left" : anchor === "end" ? "right" : "center"
} ${direction === "top" ? "bottom" : "top"}`,
willChange: "transform",
zIndex: open ? 50 : "auto",
}}
transition={
reduceMotion || motionDisabled
? { duration: 0 }
: { bounce: 0.15, type: "spring", visualDuration: 0.25 }
}
>
<div ref={measureRef}>{children}</div>
{open && edgeDraggable ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-10"
>
<div
className="pointer-events-auto absolute inset-x-0 top-0 h-2 touch-none cursor-grab active:cursor-grabbing"
data-bestie-drag-edge="top"
data-bestie-drag-handle
/>
<div
className="pointer-events-auto absolute inset-x-0 bottom-0 h-2 touch-none cursor-grab active:cursor-grabbing"
data-bestie-drag-edge="bottom"
data-bestie-drag-handle
/>
<div
className="pointer-events-auto absolute inset-y-2 left-0 w-2 touch-none cursor-grab active:cursor-grabbing"
data-bestie-drag-edge="left"
data-bestie-drag-handle
/>
<div
className="pointer-events-auto absolute inset-y-2 right-0 w-2 touch-none cursor-grab active:cursor-grabbing"
data-bestie-drag-edge="right"
data-bestie-drag-handle
/>
</div>
) : null}
</motion.div>
</div>
);
}
function Trigger({
ariaLabel,
children,
className,
}: {
ariaLabel: string;
children: React.ReactNode;
className?: string;
}) {
const { anchor, contentId, direction, open, setOpen, triggerRef } =
useBloomContext();
return (
<motion.button
aria-controls={contentId}
aria-expanded={open}
aria-haspopup="dialog"
aria-label={ariaLabel}
className={className}
disabled={open}
key="bloom-trigger"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setOpen(true);
}}
ref={triggerRef}
style={{
...(anchor === "end" ? { right: 0 } : { left: 0 }),
...(direction === "top" ? { bottom: 0 } : { top: 0 }),
pointerEvents: open ? "none" : "auto",
position: "absolute",
visibility: open ? "hidden" : "visible",
}}
tabIndex={open ? -1 : 0}
type="button"
>
{children}
</motion.button>
);
}
function Content({
ariaLabel,
children,
className,
}: {
ariaLabel: string;
children: React.ReactNode;
className?: string;
}) {
const { anchor, contentId, contentRef, direction, open } = useBloomContext();
const reduceMotion = useReducedMotion();
const hiddenY = direction === "top" ? 8 : -8;
return (
<AnimatePresence>
{open ? (
<motion.div
aria-label={ariaLabel}
aria-modal="false"
animate={{
filter: "blur(0px)",
opacity: 1,
transform: "translate3d(0, 0, 0) scale(1)",
}}
className={className}
data-testid="bestie-bloom-content"
exit={{
filter: reduceMotion ? "blur(0px)" : "blur(8px)",
opacity: 0,
transform: reduceMotion
? "translate3d(0, 0, 0) scale(1)"
: `translate3d(0, ${direction === "top" ? 24 : -24}px, 0) scale(0.95)`,
}}
initial={{
filter: reduceMotion ? "blur(0px)" : "blur(10px)",
opacity: 0,
transform: reduceMotion
? "translate3d(0, 0, 0) scale(1)"
: `translate3d(0, ${hiddenY}px, 0) scale(0.95)`,
}}
key="bloom-content"
id={contentId}
ref={contentRef}
role="dialog"
style={{
transformOrigin: `${
anchor === "start"
? "left"
: anchor === "end"
? "right"
: "center"
} ${direction === "top" ? "bottom" : "top"}`,
}}
transition={
reduceMotion
? { duration: 0.12 }
: {
bounce: 0.15,
delay: 0.03,
type: "spring",
visualDuration: 0.2,
}
}
tabIndex={-1}
>
{children}
</motion.div>
) : null}
</AnimatePresence>
);
}
export const Bloom = { Container, Content, Root, Trigger };

View File

@@ -0,0 +1,56 @@
import type { Channel } from "@/shared/api/types";
import { fromRawChannel, type RawChannel } from "@/shared/api/tauriChannels";
import { invokeTauri } from "@/shared/api/tauri";
type RawBestieAssignment = {
agent_pubkey: string;
};
export type BestieAssignment = {
agentPubkey: string;
};
export type BestieScope = {
expectedRelayUrl: string;
expectedSignerPubkey: string;
};
function fromRawAssignment(assignment: RawBestieAssignment): BestieAssignment {
return {
agentPubkey: assignment.agent_pubkey,
};
}
export async function getBestieAssignment(
scope: BestieScope,
): Promise<BestieAssignment | null> {
const assignment = await invokeTauri<RawBestieAssignment | null>(
"get_bestie_assignment",
scope,
);
return assignment ? fromRawAssignment(assignment) : null;
}
export async function assignBestie(
agentPubkey: string,
scope: BestieScope,
): Promise<BestieAssignment> {
return fromRawAssignment(
await invokeTauri<RawBestieAssignment>("assign_bestie", {
agentPubkey,
...scope,
}),
);
}
export async function clearBestieAssignment(scope: BestieScope): Promise<void> {
await invokeTauri("clear_bestie_assignment", scope);
}
export async function resolveBestieConversation(
scope: BestieScope,
): Promise<Channel> {
return fromRawChannel(
await invokeTauri<RawChannel>("resolve_bestie_conversation", scope),
);
}

View File

@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildBestieMessageContext } from "./bestieMessageContext.ts";
const CHANNEL_ID = "channel-1";
function message(overrides = {}) {
return {
id: "message-1",
author: "Baxen",
body: "This visible body must not be copied into the agent prompt.",
createdAt: 1,
depth: 0,
time: "8:52 AM",
...overrides,
};
}
test("sends a canonical link to a top-level message instead of its body", () => {
const context = buildBestieMessageContext(CHANNEL_ID, message());
assert.equal(
context,
"Help me with this thread from Baxen:\n\nbuzz://message?channel=channel-1&id=message-1",
);
assert.doesNotMatch(context, /visible body/);
});
test("links a reply to its thread root", () => {
const context = buildBestieMessageContext(
CHANNEL_ID,
message({ id: "reply-1", rootId: "root-1", parentId: "root-1" }),
);
assert.equal(
context,
"Help me with this thread from Baxen:\n\nbuzz://message?channel=channel-1&id=root-1&thread=root-1",
);
});
test("does not construct a lossy fallback when channel context is missing", () => {
assert.equal(buildBestieMessageContext(null, message()), null);
assert.equal(buildBestieMessageContext(CHANNEL_ID, undefined), null);
});

View File

@@ -0,0 +1,18 @@
import { buildMessageLink } from "@/features/messages/lib/messageLink";
import type { TimelineMessage } from "@/features/messages/types";
export function buildBestieMessageContext(
channelId: string | null | undefined,
message: TimelineMessage | undefined,
): string | null {
if (!channelId || !message) return null;
const threadRootId = message.rootId ?? message.id;
const threadLink = buildMessageLink({
channelId,
messageId: threadRootId,
threadRootId: message.rootId ? threadRootId : null,
});
return `Help me with this thread from ${message.author}:\n\n${threadLink}`;
}

View File

@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";
import { filterBestieDmChannels } from "./filterBestieDmChannels.ts";
const SELF = "AA";
const BESTIE = "BB";
const OTHER = "CC";
function makeDm(id, participantPubkeys) {
return {
archivedAt: null,
channelType: "dm",
description: "",
id,
isMember: true,
lastMessageAt: null,
memberCount: participantPubkeys.length,
memberPubkeys: participantPubkeys,
name: id,
participantPubkeys,
participants: participantPubkeys,
purpose: null,
topic: null,
ttlDeadline: null,
ttlSeconds: null,
visibility: "private",
};
}
test("filters only the one-to-one DM with the designated Bestie", () => {
const bestieDm = makeDm("bestie", [BESTIE, SELF]);
const otherDm = makeDm("other", [SELF, OTHER]);
const groupWithBestie = makeDm("group", [SELF, BESTIE, OTHER]);
assert.deepEqual(
filterBestieDmChannels(
[bestieDm, otherDm, groupWithBestie],
SELF.toLowerCase(),
BESTIE.toLowerCase(),
).map(({ id }) => id),
["other", "group"],
);
});
test("preserves the DM list until identity and assignment are available", () => {
const channels = [makeDm("bestie", [SELF, BESTIE])];
assert.equal(filterBestieDmChannels(channels, undefined, BESTIE), channels);
assert.equal(filterBestieDmChannels(channels, SELF, null), channels);
});

View File

@@ -0,0 +1,25 @@
import type { Channel } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
export function filterBestieDmChannels(
channels: Channel[],
currentPubkey: string | undefined,
bestiePubkey: string | null,
) {
if (!currentPubkey || !bestiePubkey) return channels;
const expectedParticipants = new Set([
normalizePubkey(currentPubkey),
normalizePubkey(bestiePubkey),
]);
return channels.filter((channel) => {
const participants = new Set(
channel.participantPubkeys.map(normalizePubkey),
);
const isBestiePair =
participants.size === expectedParticipants.size &&
[...expectedParticipants].every((pubkey) => participants.has(pubkey));
return !isBestiePair;
});
}

View File

@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import test from "node:test";
import { findAssignedLocalAgent } from "./findAssignedLocalAgent.ts";
const LOCAL = {
backend: { type: "local" },
pubkey: "a".repeat(64),
};
const REMOTE = {
backend: { type: "provider" },
pubkey: "b".repeat(64),
};
test("resolves only an existing local Bestie assignment", () => {
assert.equal(
findAssignedLocalAgent([LOCAL, REMOTE], { agentPubkey: "A".repeat(64) }),
LOCAL,
);
assert.equal(
findAssignedLocalAgent([LOCAL, REMOTE], { agentPubkey: REMOTE.pubkey }),
null,
);
assert.equal(
findAssignedLocalAgent([LOCAL], { agentPubkey: "c".repeat(64) }),
null,
);
assert.equal(findAssignedLocalAgent([LOCAL], null), null);
});

View File

@@ -0,0 +1,17 @@
import type { ManagedAgent } from "@/shared/api/types";
import type { BestieAssignment } from "./api";
/** Resolve a durable assignment only when its local managed agent still exists. */
export function findAssignedLocalAgent(
agents: ManagedAgent[],
assignment: BestieAssignment | null | undefined,
) {
if (!assignment) return null;
return (
agents.find(
(agent) =>
agent.backend.type === "local" &&
agent.pubkey.toLowerCase() === assignment.agentPubkey.toLowerCase(),
) ?? null
);
}

View File

@@ -0,0 +1,180 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import {
managedAgentRuntimesQueryKey,
useManagedAgentRuntimeAction,
useManagedAgentRuntimesQuery,
} from "@/features/agents/managedAgentRuntimeHooks";
import {
canonicalRelayUrl,
findManagedAgentRuntime,
managedAgentPairAction,
} from "@/features/agents/managedAgentRuntimeStatus";
import {
channelsQueryKey,
upsertCachedChannel,
} from "@/features/channels/hooks";
import { dmVisibilityQueryKeyFor } from "@/features/channels/useHiddenDmIds";
import { useCommunities } from "@/features/communities/useCommunities";
import { usePresenceQuery } from "@/features/presence/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { Channel, ManagedAgent } from "@/shared/api/types";
import {
assignBestie,
clearBestieAssignment,
getBestieAssignment,
resolveBestieConversation,
type BestieScope,
} from "./api";
import { findAssignedLocalAgent } from "./findAssignedLocalAgent";
export function bestieAssignmentQueryKey(
relayUrl: string,
ownerPubkey: string,
) {
return [
"bestie-assignment",
canonicalRelayUrl(relayUrl) ?? relayUrl,
ownerPubkey.toLowerCase(),
] as const;
}
export function useBestieAssignmentQuery(enabled = true) {
const { activeCommunity } = useCommunities();
const identityQuery = useIdentityQuery();
const relayUrl = activeCommunity?.relayUrl ?? "";
const ownerPubkey = identityQuery.data?.pubkey ?? "";
const scope: BestieScope | null =
relayUrl && ownerPubkey
? {
expectedRelayUrl: relayUrl,
expectedSignerPubkey: ownerPubkey,
}
: null;
const queryKey = bestieAssignmentQueryKey(relayUrl, ownerPubkey);
const assignmentQuery = useQuery({
enabled: enabled && scope !== null,
queryKey,
queryFn: () => {
if (!scope) return null;
return getBestieAssignment(scope);
},
});
return { assignmentQuery, ownerPubkey, queryKey, relayUrl, scope };
}
export function useBestie() {
const queryClient = useQueryClient();
const { goChannel } = useAppNavigation();
const { assignmentQuery, ownerPubkey, queryKey, relayUrl, scope } =
useBestieAssignmentQuery();
const managedAgentsQuery = useManagedAgentsQuery({ enabled: scope !== null });
const runtimesQuery = useManagedAgentRuntimesQuery({
enabled: scope !== null,
});
const runtimeAction = useManagedAgentRuntimeAction();
const assignedAgent = findAssignedLocalAgent(
managedAgentsQuery.data ?? [],
assignmentQuery.data,
);
const presenceQuery = usePresenceQuery(
assignedAgent ? [assignedAgent.pubkey] : [],
{ enabled: assignedAgent !== null },
);
const presenceStatus = assignedAgent
? (presenceQuery.data?.[assignedAgent.pubkey.toLowerCase()] ?? "offline")
: undefined;
const runtime = assignedAgent
? findManagedAgentRuntime(
runtimesQuery.data ?? [],
assignedAgent.pubkey,
relayUrl,
)
: undefined;
const assignMutation = useMutation({
mutationFn: (agent: ManagedAgent) => {
if (!scope) throw new Error("Bestie is unavailable outside a community");
return assignBestie(agent.pubkey, scope);
},
onSuccess: (assignment) => {
queryClient.setQueryData(queryKey, assignment);
},
});
const clearMutation = useMutation({
mutationFn: () => {
if (!scope) throw new Error("Bestie is unavailable outside a community");
return clearBestieAssignment(scope);
},
onSuccess: () => {
queryClient.setQueryData(queryKey, null);
},
});
const resolveMutation = useMutation({
mutationFn: () => {
if (!scope) throw new Error("Bestie is unavailable outside a community");
return resolveBestieConversation(scope);
},
onSuccess: (channel) => {
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current) =>
upsertCachedChannel(current, channel),
);
queryClient.setQueryData<Set<string>>(
dmVisibilityQueryKeyFor(relayUrl, ownerPubkey),
(current) => {
const next = new Set(current);
next.delete(channel.id);
return next;
},
);
},
});
const ensureAgentRunning = async () => {
if (assignedAgent) {
const action = managedAgentPairAction(runtime);
if (action !== "stop") {
await runtimeAction.mutateAsync({
action,
pubkey: assignedAgent.pubkey,
relayUrl,
});
await queryClient.invalidateQueries({
queryKey: managedAgentRuntimesQueryKey,
});
}
}
};
const resolveConversation = async () => {
return resolveMutation.mutateAsync();
};
const openConversation = async (draft?: string) => {
await ensureAgentRunning();
const channel = await resolveConversation();
await goChannel(channel.id, draft ? { autoSend: draft } : undefined);
};
return {
assignedAgent,
assignment: assignmentQuery.data ?? null,
assignmentError: assignmentQuery.error,
assignAgent: assignMutation.mutateAsync,
clearAssignment: clearMutation.mutateAsync,
ensureAgentRunning,
isAssigning: assignMutation.isPending,
isLoading:
assignmentQuery.isLoading ||
managedAgentsQuery.isLoading ||
runtimesQuery.isLoading,
isOpening: resolveMutation.isPending || runtimeAction.isPending,
openConversation,
presenceStatus,
resolveConversation,
runtime,
};
}

View File

@@ -0,0 +1,89 @@
import {
createContext,
createElement,
type ReactNode,
useContext,
useMemo,
} from "react";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import type { TimelineMessage } from "@/features/messages/types";
import type { Channel, ManagedAgent } from "@/shared/api/types";
import { useFeatureEnabled } from "@/shared/features";
import { BestieGlobalOverlay } from "./bestie/BestieGlobalOverlay";
import { BestieCardBadge } from "./bestie/BestieCardBadge";
import { BestieMessageAction } from "./bestie/BestieMessageAction";
import { BestieProfileAction } from "./bestie/BestieProfileSection";
import { BestieSidebarEntry } from "./bestie/BestieSidebarEntry";
import { filterBestieDmChannels } from "./bestie/filterBestieDmChannels";
import { findAssignedLocalAgent } from "./bestie/findAssignedLocalAgent";
import { useBestieAssignmentQuery } from "./bestie/useBestie";
const ProtectedMessageActionsContext = createContext(true);
export function ProtectedGlobalOverlay() {
const enabled = useFeatureEnabled("bestie");
return enabled ? createElement(BestieGlobalOverlay) : null;
}
export function ProtectedMessageAction(props: {
channelId?: string | null;
message: TimelineMessage;
}) {
const enabled = useFeatureEnabled("bestie");
const actionsAllowed = useContext(ProtectedMessageActionsContext);
return enabled && actionsAllowed
? createElement(BestieMessageAction, props)
: null;
}
export function ProtectedMessageActionsBoundary({
children,
}: {
children: ReactNode;
}) {
return createElement(
ProtectedMessageActionsContext.Provider,
{ value: false },
children,
);
}
export function ProtectedAgentBestieAction(props: { agent: ManagedAgent }) {
const enabled = useFeatureEnabled("bestie");
return enabled ? createElement(BestieProfileAction, props) : null;
}
export function ProtectedBestieCardBadge(props: {
agent: ManagedAgent;
isBestie: boolean;
}) {
const enabled = useFeatureEnabled("bestie");
return enabled ? createElement(BestieCardBadge, props) : null;
}
export function ProtectedBestieSidebarEntry() {
const enabled = useFeatureEnabled("bestie");
return enabled ? createElement(BestieSidebarEntry) : null;
}
export function useProtectedBestiePubkey(agents: ManagedAgent[]) {
const enabled = useFeatureEnabled("bestie");
const { assignmentQuery } = useBestieAssignmentQuery(enabled);
if (!enabled) return null;
return findAssignedLocalAgent(agents, assignmentQuery.data)?.pubkey ?? null;
}
export function useProtectedVisibleDirectMessages(
channels: Channel[],
currentPubkey: string | undefined,
) {
const enabled = useFeatureEnabled("bestie");
const managedAgentsQuery = useManagedAgentsQuery({ enabled });
const bestiePubkey = useProtectedBestiePubkey(managedAgentsQuery.data ?? []);
return useMemo(
() => filterBestieDmChannels(channels, currentPubkey, bestiePubkey),
[bestiePubkey, channels, currentPubkey],
);
}

View File

@@ -0,0 +1,51 @@
import type { ReactNode } from "react";
import type { TimelineMessage } from "@/features/messages/types";
import type { Channel, ManagedAgent } from "@/shared/api/types";
export function ProtectedGlobalOverlay() {
return null;
}
export function ProtectedMessageAction(_props: {
channelId?: string | null;
message: TimelineMessage;
}) {
return null;
}
export function ProtectedMessageActionsBoundary({
children,
}: {
children: ReactNode;
}) {
return children;
}
export function ProtectedAgentBestieAction(_props: { agent: ManagedAgent }) {
return null;
}
export function ProtectedBestieCardBadge(_props: {
agent: ManagedAgent;
isBestie: boolean;
}) {
return null;
}
export function ProtectedBestieSidebarEntry() {
return null;
}
export function useProtectedBestiePubkey(
_agents: ManagedAgent[],
): string | null {
return null;
}
export function useProtectedVisibleDirectMessages(
channels: Channel[],
_currentPubkey: string | undefined,
) {
return channels;
}