mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +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:
25
desktop/src/protectedFeatures/bestie/BestieCardBadge.tsx
Normal file
25
desktop/src/protectedFeatures/bestie/BestieCardBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
212
desktop/src/protectedFeatures/bestie/BestieGlobalOverlay.tsx
Normal file
212
desktop/src/protectedFeatures/bestie/BestieGlobalOverlay.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
46
desktop/src/protectedFeatures/bestie/BestieMessageAction.tsx
Normal file
46
desktop/src/protectedFeatures/bestie/BestieMessageAction.tsx
Normal 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";
|
||||
496
desktop/src/protectedFeatures/bestie/BestiePopover.tsx
Normal file
496
desktop/src/protectedFeatures/bestie/BestiePopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
desktop/src/protectedFeatures/bestie/BestieSidebarEntry.tsx
Normal file
50
desktop/src/protectedFeatures/bestie/BestieSidebarEntry.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
362
desktop/src/protectedFeatures/bestie/BloomMenu.tsx
Normal file
362
desktop/src/protectedFeatures/bestie/BloomMenu.tsx
Normal 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 };
|
||||
56
desktop/src/protectedFeatures/bestie/api.ts
Normal file
56
desktop/src/protectedFeatures/bestie/api.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
18
desktop/src/protectedFeatures/bestie/bestieMessageContext.ts
Normal file
18
desktop/src/protectedFeatures/bestie/bestieMessageContext.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
180
desktop/src/protectedFeatures/bestie/useBestie.ts
Normal file
180
desktop/src/protectedFeatures/bestie/useBestie.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
89
desktop/src/protectedFeatures/internalUi.ts
Normal file
89
desktop/src/protectedFeatures/internalUi.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
51
desktop/src/protectedFeatures/publicUi.ts
Normal file
51
desktop/src/protectedFeatures/publicUi.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user