Move agent activity below composer (#579)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Wes <wesb@block.xyz>
This commit is contained in:
thomaspblock
2026-05-14 09:46:52 -07:00
committed by GitHub
co-authored by Cursor Wes
parent bda98da771
commit aad564b130
17 changed files with 641 additions and 189 deletions
@@ -269,7 +269,9 @@ export function getAgentTranscript(
return state?.items ?? EMPTY_TRANSCRIPT;
}
export function useManagedAgentObserverBridge(agents: readonly ManagedAgent[]) {
export function useManagedAgentObserverBridge(
agents: readonly Pick<ManagedAgent, "pubkey" | "status">[],
) {
const hasActiveAgent = React.useMemo(
() =>
agents.some(
@@ -8,6 +8,7 @@ import {
XCircle,
} from "lucide-react";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import type { ManagedAgent } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Badge } from "@/shared/ui/badge";
@@ -23,7 +24,7 @@ import { shorten } from "./agentSessionUtils";
import { useObserverEvents, useAgentTranscript } from "./useObserverEvents";
type ManagedAgentSessionPanelProps = {
agent: ManagedAgent;
agent: Pick<ManagedAgent, "pubkey" | "name" | "status">;
channelId?: string | null;
className?: string;
emptyDescription?: string;
@@ -39,12 +40,12 @@ export function ManagedAgentSessionPanel({
showHeader = true,
showRaw = true,
}: ManagedAgentSessionPanelProps) {
const isRunning = agent.status === "running";
const hasObserver = isManagedAgentActive(agent);
const { connectionState, errorMessage, events } = useObserverEvents(
isRunning,
hasObserver,
agent.pubkey,
);
const transcript = useAgentTranscript(isRunning, agent.pubkey);
const transcript = useAgentTranscript(hasObserver, agent.pubkey);
// Filter transcript items by channelId (lightweight — items now carry channelId)
const scopedTranscript = React.useMemo(
@@ -83,7 +84,7 @@ export function ManagedAgentSessionPanel({
<SessionHeader
connectionState={connectionState}
eventCount={scopedEvents.length}
hasObserver={isRunning}
hasObserver={hasObserver}
latestSessionId={latestSessionId}
/>
) : null}
@@ -94,7 +95,7 @@ export function ManagedAgentSessionPanel({
emptyDescription={emptyDescription}
errorMessage={errorMessage}
events={scopedEvents}
hasObserver={isRunning}
hasObserver={hasObserver}
showRaw={showRaw}
transcript={scopedTranscript}
/>
@@ -3,8 +3,9 @@ import { Activity, Bot, CircleDot, Octagon, X } from "lucide-react";
import { toast } from "sonner";
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import { cancelManagedAgentTurn } from "@/shared/api/agentControl";
import type { Channel, ManagedAgent } from "@/shared/api/types";
import type { Channel } from "@/shared/api/types";
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { useStickToBottom } from "@/shared/hooks/useStickToBottom";
@@ -17,11 +18,13 @@ import {
PANEL_OVERLAY_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions";
type AgentSessionThreadPanelProps = {
agent: ManagedAgent;
agent: ChannelAgentSessionAgent;
canResetWidth: boolean;
channel: Channel;
canInterruptTurn: boolean;
isWorking: boolean;
onClose: () => void;
onResetWidth: () => void;
@@ -32,6 +35,7 @@ type AgentSessionThreadPanelProps = {
export function AgentSessionThreadPanel({
agent,
canResetWidth,
canInterruptTurn,
channel,
isWorking,
onClose,
@@ -39,7 +43,7 @@ export function AgentSessionThreadPanel({
onResizeStart,
widthPx,
}: AgentSessionThreadPanelProps) {
const isLive = agent.status === "running";
const isLive = isManagedAgentActive(agent);
const isOverlay = useIsThreadPanelOverlay();
useEscapeKey(onClose, isOverlay);
@@ -118,7 +122,7 @@ export function AgentSessionThreadPanel({
<Button
aria-label="Stop current agent turn"
data-testid="agent-session-stop-turn"
disabled={!isLive || !isWorking}
disabled={!canInterruptTurn || !isLive || !isWorking}
onClick={() => {
void handleInterruptTurn();
}}
@@ -132,7 +136,9 @@ export function AgentSessionThreadPanel({
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{isWorking
? "Interrupt the current ACP turn without stopping the agent process."
? canInterruptTurn
? "Interrupt the current ACP turn without stopping the agent process."
: "This agent cannot be interrupted from this workspace."
: "No active turn to interrupt."}
</TooltipContent>
</Tooltip>
@@ -1,6 +1,9 @@
import * as React from "react";
import { Loader2 } from "lucide-react";
import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents";
import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes";
import { formatToolTitle } from "@/features/agents/ui/agentSessionToolCatalog";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ManagedAgent } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
@@ -11,21 +14,38 @@ export type BotActivityAgent = Pick<ManagedAgent, "pubkey" | "name">;
type BotActivityBarProps = {
agents: BotActivityAgent[];
channelId?: string | null;
onOpenAgentSession: (pubkey: string) => void;
openAgentSessionPubkey: string | null;
profiles?: UserProfileLookup;
typingBotPubkeys: string[];
variant?: "toolbar" | "inline";
};
const HOVER_OPEN_DELAY_MS = 150;
const HOVER_CLOSE_DELAY_MS = 180;
const HEADLINE_ROTATION_MS = 2200;
function getActivityHeadline(item: TranscriptItem): string | null {
if (item.type === "tool") {
return formatToolTitle(item.sproutToolName ?? item.toolName, item.title);
}
if (item.type === "message") {
return item.role === "assistant" ? "Responding" : item.title;
}
return item.title;
}
export function BotActivityComposerAction({
agents,
channelId = null,
onOpenAgentSession,
openAgentSessionPubkey,
profiles,
typingBotPubkeys,
variant = "toolbar",
}: BotActivityBarProps) {
const [open, setOpen] = React.useState(false);
const hoverTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
@@ -39,6 +59,39 @@ export function BotActivityComposerAction({
return agents.filter((agent) => typingSet.has(agent.pubkey.toLowerCase()));
}, [agents, typingBotPubkeys]);
const singleTypingAgent =
typingAgents.length === 1 ? (typingAgents[0] ?? null) : null;
const transcript = useAgentTranscript(
Boolean(singleTypingAgent),
singleTypingAgent?.pubkey,
);
const activityHeadlines = React.useMemo(() => {
if (!singleTypingAgent) {
return [];
}
const seen = new Set<string>();
const headlines: string[] = [];
const scopedTranscript = channelId
? transcript.filter((item) => item.channelId === channelId)
: transcript;
for (let i = scopedTranscript.length - 1; i >= 0; i--) {
const headline = getActivityHeadline(scopedTranscript[i]);
if (!headline || seen.has(headline)) {
continue;
}
seen.add(headline);
headlines.unshift(headline);
if (headlines.length >= 5) {
break;
}
}
return headlines;
}, [channelId, singleTypingAgent, transcript]);
const [headlineIndex, setHeadlineIndex] = React.useState(0);
const clearHoverTimer = React.useCallback(() => {
if (hoverTimerRef.current !== null) {
@@ -69,6 +122,18 @@ export function BotActivityComposerAction({
return () => clearHoverTimer();
}, [clearHoverTimer]);
React.useEffect(() => {
if (activityHeadlines.length <= 1) {
return;
}
const interval = window.setInterval(() => {
setHeadlineIndex((current) => (current + 1) % activityHeadlines.length);
}, HEADLINE_ROTATION_MS);
return () => window.clearInterval(interval);
}, [activityHeadlines.length]);
if (typingAgents.length === 0) {
return null;
}
@@ -80,13 +145,26 @@ export function BotActivityComposerAction({
typingAgents.length === 1
? `${typingAgents[0]?.name ?? "Agent"} is working`
: `${typingAgents.length} agents working`;
const isInline = variant === "inline";
const visibleStatusLabel =
typingAgents.length === 1
? `${typingAgents[0]?.name ?? "Agent"}: ${
activityHeadlines[headlineIndex % activityHeadlines.length] ??
"Working"
}`
: `${typingAgents[0]?.name ?? "Agent"} +${typingAgents.length - 1}`;
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<button
aria-label={`${triggerLabel}. View activity.`}
className="inline-flex h-9 min-w-9 items-center justify-center gap-1.5 rounded-full border border-border/60 bg-background px-2 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/30 hover:bg-primary/5 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:border-primary/40 data-[state=open]:bg-primary/10 data-[state=open]:text-primary"
className={cn(
"inline-flex items-center justify-center rounded-full border border-border/60 bg-background font-medium text-muted-foreground transition-colors hover:border-primary/30 hover:bg-primary/5 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:border-primary/40 data-[state=open]:bg-primary/10 data-[state=open]:text-primary",
isInline
? "h-7 min-w-0 gap-2 border-transparent bg-transparent px-0 text-xs font-semibold leading-none shadow-none hover:border-transparent hover:bg-transparent data-[state=open]:border-transparent data-[state=open]:bg-transparent"
: "h-9 min-w-9 gap-1.5 px-2 text-xs",
)}
data-testid="bot-activity-composer-trigger"
onBlur={closeWithDelay}
onClick={() => {
@@ -102,7 +180,12 @@ export function BotActivityComposerAction({
{typingAgents.slice(0, 2).map((agent) => (
<UserAvatar
avatarUrl={agentAvatarUrl(agent)}
className="!h-5 !w-5 rounded-full border border-background text-[8px]"
className={cn(
"rounded-full border border-background",
isInline
? "!h-[18px] !w-[18px] shadow-sm ring-1 ring-primary/25 text-[7px]"
: "!h-5 !w-5 text-[8px]",
)}
displayName={agent.name}
key={agent.pubkey}
/>
@@ -113,11 +196,20 @@ export function BotActivityComposerAction({
+{typingAgents.length - 2}
</span>
) : null}
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin opacity-70" />
<span
className={cn(
isInline ? "agent-activity-shimmer max-w-40 truncate" : "sr-only",
)}
>
{isInline ? visibleStatusLabel : "working"}
</span>
{isInline ? null : (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin opacity-70" />
)}
</button>
</PopoverTrigger>
<PopoverContent
align="end"
align={isInline ? "start" : "end"}
className="w-64 p-2"
onMouseEnter={keepOpen}
onMouseLeave={closeWithDelay}
@@ -13,12 +13,13 @@ import {
BotActivityComposerAction,
type BotActivityAgent,
} from "@/features/channels/ui/BotActivityBar";
import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions";
import { Button } from "@/shared/ui/button";
import type { useChannelFind } from "@/features/search/useChannelFind";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { Channel, ManagedAgent } from "@/shared/api/types";
import type { Channel } from "@/shared/api/types";
const THREAD_PANEL_DEFAULT_WIDTH_PX = 380;
const THREAD_PANEL_MIN_WIDTH_PX = 320;
@@ -57,7 +58,7 @@ function getInitialThreadPanelWidth(): number {
type ChannelPaneProps = {
activeChannel: Channel | null;
activityAgents?: BotActivityAgent[];
agentSessionAgents: ManagedAgent[];
agentSessionAgents: ChannelAgentSessionAgent[];
botTypingEntries: TypingIndicatorEntry[];
channelFind: ReturnType<typeof useChannelFind>;
currentPubkey?: string;
@@ -250,6 +251,10 @@ export const ChannelPane = React.memo(function ChannelPane({
const composerBotTypingPubkeys = React.useMemo(() => {
const pubkeys: string[] = [];
for (const entry of botTypingEntries) {
if (entry.threadHeadId !== null) {
continue;
}
if (
!pubkeys.some(
(pubkey) => pubkey.toLowerCase() === entry.pubkey.toLowerCase(),
@@ -260,6 +265,7 @@ export const ChannelPane = React.memo(function ChannelPane({
}
return pubkeys;
}, [botTypingEntries]);
const hasComposerBotActivity = composerBotTypingPubkeys.length > 0;
const threadComposerBotTypingPubkeys = React.useMemo(() => {
if (!openThreadHeadId) {
return [];
@@ -281,6 +287,8 @@ export const ChannelPane = React.memo(function ChannelPane({
}
return pubkeys;
}, [botTypingEntries, openThreadHeadId]);
const hasThreadComposerBotActivity =
threadComposerBotTypingPubkeys.length > 0;
const selectedAgent = React.useMemo(
() =>
@@ -378,15 +386,6 @@ export const ChannelPane = React.memo(function ChannelPane({
onEditSave={onEditSave}
onSend={onSendMessage}
profiles={profiles}
toolbarExtraActions={
<BotActivityComposerAction
agents={activityAgents}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
typingBotPubkeys={composerBotTypingPubkeys}
/>
}
placeholder={
activeChannel?.archivedAt
? "Archived channels are read-only."
@@ -398,16 +397,32 @@ export const ChannelPane = React.memo(function ChannelPane({
}
showTopBorder={false}
/>
<div className="h-6 bg-background">
{hasTypingActivity ? (
<TypingIndicatorRow
channel={activeChannel}
className="px-4 pb-1 pt-0 sm:px-6"
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
/>
) : null}
<div className="h-7 bg-background px-4 pb-1 pt-0 sm:px-6 -mt-1">
<div className="mx-auto flex h-full w-full max-w-4xl items-center gap-2">
{hasComposerBotActivity ? (
<div className="shrink-0">
<BotActivityComposerAction
agents={activityAgents}
channelId={activeChannel?.id ?? null}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
typingBotPubkeys={composerBotTypingPubkeys}
variant="inline"
/>
</div>
) : null}
{hasTypingActivity ? (
<TypingIndicatorRow
channel={activeChannel}
className="min-w-0 flex-1 px-0 py-0"
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
variant="activity"
/>
) : null}
</div>
</div>
</div>
</div>
@@ -446,19 +461,24 @@ export const ChannelPane = React.memo(function ChannelPane({
threadReplies={threadMessages}
threadTypingPubkeys={threadTypingPubkeys}
toolbarExtraActions={
<BotActivityComposerAction
agents={activityAgents}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
typingBotPubkeys={threadComposerBotTypingPubkeys}
/>
hasThreadComposerBotActivity ? (
<BotActivityComposerAction
agents={activityAgents}
channelId={activeChannel?.id ?? null}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
typingBotPubkeys={threadComposerBotTypingPubkeys}
variant="inline"
/>
) : null
}
/>
) : activeChannel && selectedAgent ? (
<AgentSessionThreadPanel
agent={selectedAgent}
canResetWidth={canResetThreadPanelWidth}
canInterruptTurn={selectedAgent.canInterruptTurn}
channel={activeChannel}
isWorking={botTypingEntries.some(
(entry) =>
@@ -16,6 +16,7 @@ import { MembersSidebar } from "@/features/channels/ui/MembersSidebar";
import {
useManagedAgentsQuery,
usePersonasQuery,
useRelayAgentsQuery,
} from "@/features/agents/hooks";
import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore";
import {
@@ -34,10 +35,7 @@ import {
import { buildThreadPanelData } from "@/features/messages/lib/threadPanel";
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors";
import {
type TypingIndicatorEntry,
useChannelTyping,
} from "@/features/messages/useChannelTyping";
import { useChannelTyping } from "@/features/messages/useChannelTyping";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
import type {
@@ -51,6 +49,10 @@ import { useChannelFind } from "@/features/search/useChannelFind";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
import { AgentSessionProvider } from "@/shared/context/AgentSessionContext";
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
import {
mergeAgentNamesIntoProfiles,
useChannelActivityTyping,
} from "./useChannelActivityTyping";
import { useChannelAgentSessions } from "./useChannelAgentSessions";
import { useChannelProfilePanel } from "./useChannelProfilePanel";
type ChannelScreenProps = {
@@ -145,13 +147,6 @@ export function ChannelScreen({
currentPubkey,
latestMessageEvent,
);
const threadTypingPubkeys = React.useMemo(
() =>
typingEntries
.filter((entry) => entry.threadHeadId === openThreadHeadId)
.map((entry) => entry.pubkey),
[openThreadHeadId, typingEntries],
);
const messageProfilePubkeys = React.useMemo(
() => [
...new Set([
@@ -164,59 +159,40 @@ export function ChannelScreen({
const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, {
enabled: messageProfilePubkeys.length > 0,
});
const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null);
const channelMembers = channelMembersQuery.data;
const managedAgentsQuery = useManagedAgentsQuery();
useManagedAgentObserverBridge(managedAgentsQuery.data ?? []);
const { botTypingEntries, humanTypingPubkeys } = React.useMemo<{
botTypingEntries: TypingIndicatorEntry[];
humanTypingPubkeys: string[];
}>(() => {
const localAgentSet = new Set(
(managedAgentsQuery.data ?? [])
.filter((agent) => agent.backend.type === "local")
.map((agent) => agent.pubkey.toLowerCase()),
);
const channelTypingEntries = typingEntries.filter(
(entry) => entry.threadHeadId === null,
);
const agentTypingEntries = typingEntries.filter((entry) =>
localAgentSet.has(entry.pubkey.toLowerCase()),
);
return {
botTypingEntries: agentTypingEntries,
humanTypingPubkeys: channelTypingEntries
.filter((entry) => !localAgentSet.has(entry.pubkey.toLowerCase()))
.map((entry) => entry.pubkey),
};
}, [managedAgentsQuery.data, typingEntries]);
const managedAgents = managedAgentsQuery.data ?? [];
const relayAgentsQuery = useRelayAgentsQuery();
const relayAgents = relayAgentsQuery.data ?? [];
const {
botTypingEntries,
channelAgentSessionAgents: activeChannelAgentSessionAgents,
humanTypingPubkeys,
threadTypingPubkeys,
} = useChannelActivityTyping({
activeChannel,
activeChannelId,
channelMembers,
managedAgents,
openThreadHeadId,
relayAgents,
typingEntries,
});
useManagedAgentObserverBridge(activeChannelAgentSessionAgents);
const messageProfiles = React.useMemo(() => {
const base =
mergeCurrentProfileIntoLookup(
messageProfilesQuery.data?.profiles,
currentProfile,
) ?? {};
// Merge managed agent names so system messages resolve instantly
// (without waiting for the relay profile batch query).
const agents = managedAgentsQuery.data ?? [];
const merged = { ...base };
for (const agent of agents) {
const key = agent.pubkey.toLowerCase();
if (!merged[key]?.displayName) {
merged[key] = {
...merged[key],
displayName: agent.name,
avatarUrl: null,
nip05Handle: null,
};
}
}
return merged;
return mergeAgentNamesIntoProfiles(base, managedAgents, relayAgents);
}, [
currentProfile,
managedAgentsQuery.data,
managedAgents,
messageProfilesQuery.data?.profiles,
relayAgents,
]);
const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null);
const channelMembers = channelMembersQuery.data;
const personasQuery = usePersonasQuery();
const { personaLookup, respondToLookup } = React.useMemo(() => {
const agents = managedAgentsQuery.data ?? [];
@@ -365,7 +341,7 @@ export function ChannelScreen({
activeChannelId,
channelMembers,
handleOpenThread,
managedAgents: managedAgentsQuery.data ?? [],
managedAgents: activeChannelAgentSessionAgents,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
setProfilePanelPubkey,
@@ -0,0 +1,113 @@
import * as React from "react";
import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type {
Channel,
ChannelMember,
ManagedAgent,
RelayAgent,
} from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
buildChannelAgentSessionCandidates,
getChannelAgentSessionAgents,
} from "./useChannelAgentSessions";
export function useChannelActivityTyping({
activeChannel,
activeChannelId,
channelMembers,
managedAgents,
openThreadHeadId,
relayAgents,
typingEntries,
}: {
activeChannel: Channel | null;
activeChannelId: string | null;
channelMembers?: ChannelMember[];
managedAgents: ManagedAgent[];
openThreadHeadId: string | null;
relayAgents: RelayAgent[];
typingEntries: TypingIndicatorEntry[];
}) {
const agentCandidates = React.useMemo(
() =>
buildChannelAgentSessionCandidates({
channelMembers,
managedAgents,
relayAgents,
}),
[channelMembers, managedAgents, relayAgents],
);
const channelAgentSessionAgents = React.useMemo(
() =>
getChannelAgentSessionAgents({
activeChannel,
activeChannelId,
agents: agentCandidates,
channelMembers,
}),
[activeChannel, activeChannelId, agentCandidates, channelMembers],
);
const channelAgentPubkeys = React.useMemo(
() =>
new Set(
channelAgentSessionAgents.map((agent) => normalizePubkey(agent.pubkey)),
),
[channelAgentSessionAgents],
);
const threadTypingPubkeys = React.useMemo(
() =>
typingEntries
.filter(
(entry) =>
entry.threadHeadId === openThreadHeadId &&
!channelAgentPubkeys.has(normalizePubkey(entry.pubkey)),
)
.map((entry) => entry.pubkey),
[channelAgentPubkeys, openThreadHeadId, typingEntries],
);
const { botTypingEntries, humanTypingPubkeys } = React.useMemo<{
botTypingEntries: TypingIndicatorEntry[];
humanTypingPubkeys: string[];
}>(() => {
const botTypingEntries: TypingIndicatorEntry[] = [];
const humanTypingPubkeys: string[] = [];
for (const entry of typingEntries) {
if (channelAgentPubkeys.has(normalizePubkey(entry.pubkey))) {
botTypingEntries.push(entry);
} else if (entry.threadHeadId === null) {
humanTypingPubkeys.push(entry.pubkey);
}
}
return { botTypingEntries, humanTypingPubkeys };
}, [channelAgentPubkeys, typingEntries]);
return {
botTypingEntries,
channelAgentSessionAgents,
humanTypingPubkeys,
threadTypingPubkeys,
};
}
export function mergeAgentNamesIntoProfiles(
profiles: UserProfileLookup,
managedAgents: ManagedAgent[],
relayAgents: RelayAgent[],
): UserProfileLookup {
const merged = { ...profiles };
for (const agent of [...relayAgents, ...managedAgents]) {
const key = normalizePubkey(agent.pubkey);
if (!merged[key]?.displayName) {
merged[key] = {
...merged[key],
displayName: agent.name,
avatarUrl: merged[key]?.avatarUrl ?? null,
nip05Handle: merged[key]?.nip05Handle ?? null,
};
}
}
return merged;
}
@@ -1,15 +1,30 @@
import * as React from "react";
import type { TimelineMessage } from "@/features/messages/types";
import type { Channel, ChannelMember, ManagedAgent } from "@/shared/api/types";
import type {
Channel,
ChannelMember,
ManagedAgent,
RelayAgent,
} from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
export type ChannelAgentSessionAgent = Pick<
ManagedAgent,
"pubkey" | "name" | "status"
> & {
agentSource: "managed" | "member-bot" | "relay";
canInterruptTurn: boolean;
channelIds?: string[];
channels?: string[];
};
type UseChannelAgentSessionsOptions = {
activeChannel: Channel | null;
activeChannelId: string | null;
channelMembers?: ChannelMember[];
handleOpenThread: (message: TimelineMessage) => void;
managedAgents: ManagedAgent[];
managedAgents: ChannelAgentSessionAgent[];
setExpandedThreadReplyIds: (value: Set<string>) => void;
setOpenThreadHeadId: (value: string | null) => void;
setProfilePanelPubkey: (value: string | null) => void;
@@ -19,6 +34,121 @@ type UseChannelAgentSessionsOptions = {
timelineMessages: TimelineMessage[];
};
function relayStatusToManagedStatus(
status: RelayAgent["status"],
): ManagedAgent["status"] {
return status === "offline" ? "stopped" : "deployed";
}
export function buildChannelAgentSessionCandidates({
channelMembers,
managedAgents,
relayAgents,
}: {
channelMembers?: ChannelMember[];
managedAgents: ManagedAgent[];
relayAgents: RelayAgent[];
}): ChannelAgentSessionAgent[] {
const byPubkey = new Map<string, ChannelAgentSessionAgent>();
for (const agent of relayAgents) {
byPubkey.set(normalizePubkey(agent.pubkey), {
pubkey: agent.pubkey,
name: agent.name,
status: relayStatusToManagedStatus(agent.status),
agentSource: "relay",
canInterruptTurn: false,
channelIds: agent.channelIds,
channels: agent.channels,
});
}
for (const agent of managedAgents) {
const key = normalizePubkey(agent.pubkey);
const existing = byPubkey.get(key);
byPubkey.set(key, {
pubkey: agent.pubkey,
name: agent.name,
status: agent.status,
agentSource: "managed",
canInterruptTurn: true,
channelIds: existing?.channelIds,
channels: existing?.channels,
});
}
for (const member of channelMembers ?? []) {
const key = normalizePubkey(member.pubkey);
if (member.role !== "bot" || byPubkey.has(key)) {
continue;
}
byPubkey.set(key, {
pubkey: member.pubkey,
name: member.displayName ?? member.pubkey.slice(0, 8),
status: "deployed",
agentSource: "member-bot",
canInterruptTurn: false,
});
}
return [...byPubkey.values()];
}
export function getChannelAgentSessionAgents({
activeChannel,
activeChannelId,
agents,
channelMembers,
}: {
activeChannel: Channel | null;
activeChannelId: string | null;
agents: ChannelAgentSessionAgent[];
channelMembers?: ChannelMember[];
}): ChannelAgentSessionAgent[] {
if (!activeChannelId || !activeChannel) {
return [];
}
const memberPubkeys = channelMembers
? new Set(channelMembers.map((member) => normalizePubkey(member.pubkey)))
: null;
const botMemberPubkeys = channelMembers
? new Set(
channelMembers
.filter((member) => member.role === "bot")
.map((member) => normalizePubkey(member.pubkey)),
)
: null;
return agents.filter((agent) => {
const normalizedPubkey = normalizePubkey(agent.pubkey);
const channelIds = agent.channelIds ?? [];
const channels = agent.channels ?? [];
const hasDeclaredChannelScope =
channelIds.length > 0 || channels.length > 0;
const matchesDeclaredChannel =
channelIds.includes(activeChannelId) ||
channels.includes(activeChannel.name);
if (agent.agentSource === "member-bot") {
return botMemberPubkeys?.has(normalizedPubkey) ?? matchesDeclaredChannel;
}
if (agent.agentSource === "managed") {
return memberPubkeys?.has(normalizedPubkey) ?? matchesDeclaredChannel;
}
if (matchesDeclaredChannel) {
return true;
}
return (
!hasDeclaredChannelScope && Boolean(memberPubkeys?.has(normalizedPubkey))
);
});
}
export function useChannelAgentSessions({
activeChannel,
activeChannelId,
@@ -38,21 +168,16 @@ export function useChannelAgentSessions({
>(null);
const handledThreadTargetIdRef = React.useRef<string | null>(null);
const channelAgentSessionAgents = React.useMemo<ManagedAgent[]>(() => {
if (!channelMembers) {
return [];
}
const memberPubkeys = new Set(
channelMembers.map((member) => normalizePubkey(member.pubkey)),
);
return managedAgents.filter(
(agent) =>
agent.backend.type === "local" &&
memberPubkeys.has(normalizePubkey(agent.pubkey)),
);
}, [channelMembers, managedAgents]);
const channelAgentSessionAgents = React.useMemo(
() =>
getChannelAgentSessionAgents({
activeChannel,
activeChannelId,
agents: managedAgents,
channelMembers,
}),
[activeChannel, activeChannelId, channelMembers, managedAgents],
);
const closeAgentSession = React.useCallback(() => {
setOpenAgentSessionPubkey(null);
@@ -165,7 +290,9 @@ export function useChannelAgentSessions({
if (
openAgentSessionPubkey &&
!channelAgentSessionAgents.some(
(agent) => agent.pubkey === openAgentSessionPubkey,
(agent) =>
normalizePubkey(agent.pubkey) ===
normalizePubkey(openAgentSessionPubkey),
)
) {
setOpenAgentSessionPubkey(null);
@@ -310,20 +310,25 @@ export function MessageThreadPanel({
placeholder={`Reply in thread to ${threadHead.author}`}
profiles={profiles}
replyTarget={composerReplyTarget}
toolbarExtraActions={toolbarExtraActions}
typingParentEventId={threadHead.id}
typingRootEventId={threadHead.rootId}
/>
<div className="h-6 bg-background">
{threadTypingPubkeys.length > 0 ? (
<TypingIndicatorRow
channel={channel}
className="px-4 pb-1 pt-0 sm:px-6"
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={threadTypingPubkeys}
/>
) : null}
<div className="h-7 bg-background px-4 pb-1 pt-0 sm:px-6 -mt-1">
<div className="mx-auto flex h-full w-full max-w-4xl items-center gap-2">
{toolbarExtraActions ? (
<div className="shrink-0">{toolbarExtraActions}</div>
) : null}
{threadTypingPubkeys.length > 0 ? (
<TypingIndicatorRow
channel={channel}
className="min-w-0 flex-1 px-0 py-0"
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={threadTypingPubkeys}
variant="activity"
/>
) : null}
</div>
</div>
</div>
</div>
@@ -14,6 +14,7 @@ type TypingIndicatorRowProps = {
currentPubkey?: string;
profiles?: UserProfileLookup;
typingPubkeys: string[];
variant?: "default" | "activity";
};
function resolveFallbackName(channel: Channel | null, pubkey: string) {
@@ -54,7 +55,9 @@ export function TypingIndicatorRow({
currentPubkey,
profiles,
typingPubkeys,
variant = "default",
}: TypingIndicatorRowProps) {
const isActivityVariant = variant === "activity";
const labels = React.useMemo(
() =>
typingPubkeys.map((pubkey) =>
@@ -72,13 +75,25 @@ export function TypingIndicatorRow({
return (
<div
aria-live="polite"
className={cn("shrink-0 bg-transparent px-4 py-2 sm:px-6", className)}
className={cn(
"shrink-0 bg-transparent",
isActivityVariant
? "flex h-7 items-center px-0 py-0"
: "px-4 py-2 sm:px-6",
className,
)}
{...(labels.length > 0
? { "data-testid": "message-typing-indicator" }
: {})}
>
{labels.length > 0 && (
<div className="mx-auto flex w-full max-w-4xl items-center gap-2">
<div
className={cn(
"flex w-full items-center gap-2",
isActivityVariant && "h-full",
!isActivityVariant && "mx-auto max-w-4xl",
)}
>
<div className="flex shrink-0 items-center">
{typingPubkeys.map((pubkey, index) => {
const profile = profiles?.[pubkey.toLowerCase()];
@@ -86,21 +101,37 @@ export function TypingIndicatorRow({
return (
<div
key={pubkey}
className={`relative h-5 w-5 shrink-0 rounded-full ring-1 ring-background${index > 0 ? " -ml-1.5" : ""}`}
className={cn(
"relative shrink-0 rounded-full ring-1 ring-background",
isActivityVariant ? "h-[18px] w-[18px]" : "h-5 w-5",
index > 0 && "-ml-1.5",
)}
data-testid="message-typing-avatar"
>
<ProfileAvatar
avatarUrl={profile?.avatarUrl ?? null}
label={label}
className="h-5 w-5 rounded-full text-[8px]"
iconClassName="h-3 w-3"
className={cn(
"rounded-full",
isActivityVariant
? "h-[18px] w-[18px] text-[7px]"
: "h-5 w-5 text-[8px]",
)}
iconClassName={
isActivityVariant ? "h-2.5 w-2.5" : "h-3 w-3"
}
/>
</div>
);
})}
</div>
<p
className="truncate text-sm text-muted-foreground"
className={cn(
"truncate text-muted-foreground",
isActivityVariant
? "agent-activity-shimmer text-xs font-semibold leading-none"
: "text-sm",
)}
data-testid="message-typing-indicator-label"
>
{formatTypingLabel(labels)}
@@ -82,6 +82,12 @@ export function useChannelTyping(
return;
}
const now = Date.now();
const eventExpiresAt = event.created_at * 1_000 + TYPING_INDICATOR_TTL_MS;
if (eventExpiresAt <= now) {
return;
}
if (getChannelIdFromTags(event.tags) !== channelId) {
return;
}
@@ -108,14 +114,13 @@ export function useChannelTyping(
return;
}
const now = Date.now();
setTypingByPubkey((current) => {
const pruned = pruneTypingState(current, now);
const existing = pruned[typingKey];
return {
...pruned,
[typingKey]: {
expiresAt: now + TYPING_INDICATOR_TTL_MS,
expiresAt: Math.min(now + TYPING_INDICATOR_TTL_MS, eventExpiresAt),
firstSeenAt: existing?.firstSeenAt ?? now,
pubkey: typingPubkey,
threadHeadId,
@@ -95,10 +95,7 @@ export function UserProfilePanel({
const isBot = Boolean(relayAgent || managedAgent);
const isSelf =
currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase();
const canViewActivity =
isBot &&
managedAgent?.backend.type === "local" &&
Boolean(onOpenAgentSession);
const canViewActivity = isBot && Boolean(onOpenAgentSession);
const handleCopyPubkey = React.useCallback(() => {
void navigator.clipboard.writeText(pubkey).then(() => {
@@ -83,10 +83,7 @@ export function UserProfilePopover({
const managedAgent = managedAgentsQuery.data?.find(
(a) => a.pubkey === pubkey,
);
const canViewActivity =
role === "bot" &&
managedAgent?.backend.type === "local" &&
Boolean(onOpenAgentSession);
const canViewActivity = role === "bot" && Boolean(onOpenAgentSession);
const profile = profileQuery.data;
const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()];
const userStatus = userStatusQuery.data?.[pubkey.toLowerCase()];
+1 -1
View File
@@ -277,12 +277,12 @@ export class RelayClient {
kinds: [KIND_TYPING_INDICATOR],
"#h": [channelId],
limit: 10,
since: Math.floor(Date.now() / 1_000) - 10,
},
onEvent,
);
}
/** Subscribe to kind:20001 presence events (live only, no backfill). */
async subscribeToPresenceUpdates(onEvent: (event: RelayEvent) => void) {
return this.subscribe({ kinds: [20001], limit: 0 }, onEvent);
}
+41
View File
@@ -107,6 +107,47 @@
font-weight: 600;
}
.agent-activity-shimmer {
display: inline-block;
color: transparent;
background-image: linear-gradient(
90deg,
hsl(var(--muted-foreground) / 0.48) 0%,
hsl(var(--muted-foreground) / 0.58) 34%,
hsl(var(--foreground) / 0.96) 48%,
hsl(var(--foreground)) 50%,
hsl(var(--foreground) / 0.96) 52%,
hsl(var(--muted-foreground) / 0.58) 66%,
hsl(var(--muted-foreground) / 0.48) 100%
);
background-size: 260% 100%;
background-position: 100% 50%;
-webkit-background-clip: text;
background-clip: text;
animation: agent-activity-shimmer 4.8s cubic-bezier(0.65, 0, 0.35, 1) infinite
alternate;
}
@keyframes agent-activity-shimmer {
0%,
18% {
background-position: 100% 50%;
}
82%,
100% {
background-position: 0% 50%;
}
}
@media (prefers-reduced-motion: reduce) {
.agent-activity-shimmer {
color: hsl(var(--muted-foreground));
background-image: none;
animation: none;
}
}
@layer base {
:root {
/* Catppuccin Latte (mauve accent) */
+32 -15
View File
@@ -365,9 +365,14 @@ type MockManagedAgent = RawManagedAgent & {
type WsHandler = (message: unknown) => void;
const GLOBAL_MOCK_SUBSCRIPTION = "*";
type MockSubscription = {
channelId: string;
kinds: number[] | null;
};
type MockSocket = {
handler: WsHandler;
subscriptions: Map<string, string>;
subscriptions: Map<string, MockSubscription>;
};
function createMockRelayMembershipEvent(): RelayEvent {
@@ -426,6 +431,7 @@ declare global {
__SPROUT_E2E_WEBVIEW_ZOOM__?: number;
__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
kind?: number;
}) => boolean;
__SPROUT_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
@@ -1672,10 +1678,11 @@ function emitMockHistory(socket: MockSocket, subId: string, channelId: string) {
function emitMockLiveEvent(channelId: string, event: RelayEvent) {
for (const socket of mockSockets.values()) {
for (const [subId, subscribedChannelId] of socket.subscriptions) {
for (const [subId, subscription] of socket.subscriptions) {
if (
subscribedChannelId === channelId ||
subscribedChannelId === GLOBAL_MOCK_SUBSCRIPTION
(subscription.channelId === channelId ||
subscription.channelId === GLOBAL_MOCK_SUBSCRIPTION) &&
(!subscription.kinds || subscription.kinds.includes(event.kind))
) {
sendWsText(socket.handler, ["EVENT", subId, event]);
}
@@ -1683,12 +1690,15 @@ function emitMockLiveEvent(channelId: string, event: RelayEvent) {
}
}
function hasMockLiveSubscription(channelId: string) {
function hasMockLiveSubscription(channelId: string, kind?: number) {
for (const socket of mockSockets.values()) {
for (const subscribedChannelId of socket.subscriptions.values()) {
for (const subscription of socket.subscriptions.values()) {
if (
subscribedChannelId === channelId ||
subscribedChannelId === GLOBAL_MOCK_SUBSCRIPTION
(subscription.channelId === channelId ||
subscription.channelId === GLOBAL_MOCK_SUBSCRIPTION) &&
(kind === undefined ||
!subscription.kinds ||
subscription.kinds.includes(kind))
) {
return true;
}
@@ -4393,19 +4403,23 @@ function sendToMockSocket(args: {
if (subId.startsWith("live-")) {
// Collect channel IDs from all filters in the REQ
const channelIds = new Set<string>();
const kinds = new Set<number>();
for (let i = 1; i < rest.length; i++) {
const f = rest[i] as { "#h"?: string[] };
const f = rest[i] as { "#h"?: string[]; kinds?: number[] };
const cid = f["#h"]?.[0];
if (cid) channelIds.add(cid);
for (const kind of f.kinds ?? []) {
kinds.add(kind);
}
}
const onlyChannelId =
channelIds.size === 1
? (channelIds.values().next().value as string)
: undefined;
socket.subscriptions.set(
subId,
onlyChannelId ?? GLOBAL_MOCK_SUBSCRIPTION,
);
socket.subscriptions.set(subId, {
channelId: onlyChannelId ?? GLOBAL_MOCK_SUBSCRIPTION,
kinds: kinds.size > 0 ? [...kinds] : null,
});
sendWsText(socket.handler, ["EOSE", subId]);
return;
}
@@ -4521,7 +4535,10 @@ export function maybeInstallE2eTauriMocks() {
return emitMockTypingIndicator(channel.id, pubkey ?? CHARLIE_PUBKEY);
};
window.__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__ = ({ channelName }) => {
window.__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__ = ({
channelName,
kind,
}) => {
const channel = mockChannels.find(
(candidate) => candidate.name === channelName,
);
@@ -4529,7 +4546,7 @@ export function maybeInstallE2eTauriMocks() {
throw new Error(`Mock channel ${channelName} not found.`);
}
return hasMockLiveSubscription(channel.id);
return hasMockLiveSubscription(channel.id, kind);
};
window.__SPROUT_E2E_PUSH_MOCK_FEED_ITEM__ = (item) => {
const category = item.category === "mention" ? "mentions" : item.category;
+52 -30
View File
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import { KIND_TYPING_INDICATOR } from "../../src/shared/constants/kinds";
import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8);
@@ -32,22 +33,28 @@ async function openMembersSidebar(
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
kind?: number,
) {
await expect
.poll(async () => {
return page.evaluate((currentChannelName) => {
return (
(
window as Window & {
__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
}) => boolean;
}
).__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: currentChannelName,
}) ?? false
);
}, channelName);
return page.evaluate(
({ currentChannelName, kind }) => {
return (
(
window as Window & {
__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
kind?: number;
}) => boolean;
}
).__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: currentChannelName,
kind,
}) ?? false
);
},
{ currentChannelName: channelName, kind },
);
})
.toBe(true);
}
@@ -514,26 +521,37 @@ test("channel with messages shows content", async ({ page }) => {
);
});
test("shows and clears typing indicators for active channel bots", async ({
test("shows and clears activity indicators for active channel agents", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
await waitForMockLiveSubscription(page, "agents");
await waitForMockLiveSubscription(page, "agents", KIND_TYPING_INDICATOR);
await page.evaluate((pubkey) => {
window.__SPROUT_E2E_EMIT_MOCK_TYPING__?.({
channelName: "agents",
pubkey,
});
}, TEST_IDENTITIES.charlie.pubkey);
}, TEST_IDENTITIES.alice.pubkey);
await expect(page.getByTestId("message-typing-indicator")).toBeVisible();
await expect(page.getByTestId("bot-activity-composer-trigger")).toBeVisible();
await page.getByTestId("bot-activity-composer-trigger").click();
await expect(
page.getByTestId("message-typing-indicator-label"),
).toContainText("charlie is typing");
page.getByTestId(
`bot-activity-composer-item-${TEST_IDENTITIES.alice.pubkey}`,
),
).toBeVisible();
await page
.getByTestId(`bot-activity-composer-item-${TEST_IDENTITIES.alice.pubkey}`)
.click({ force: true });
await expect(page.getByTestId("agent-session-thread-panel")).toBeVisible();
await expect(page.getByTestId("agent-session-thread-panel")).toContainText(
"alice",
);
await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0);
await page.evaluate((pubkey) => {
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({
@@ -541,10 +559,12 @@ test("shows and clears typing indicators for active channel bots", async ({
content: "Done.",
pubkey,
});
}, TEST_IDENTITIES.charlie.pubkey);
}, TEST_IDENTITIES.alice.pubkey);
await expect(page.getByTestId("message-timeline")).toContainText("Done.");
await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0);
await expect(page.getByTestId("bot-activity-composer-trigger")).toHaveCount(
0,
);
await page.waitForTimeout(1_200);
await page.evaluate((pubkey) => {
@@ -552,9 +572,11 @@ test("shows and clears typing indicators for active channel bots", async ({
channelName: "agents",
pubkey,
});
}, TEST_IDENTITIES.charlie.pubkey);
}, TEST_IDENTITIES.alice.pubkey);
await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0);
await expect(page.getByTestId("bot-activity-composer-trigger")).toHaveCount(
0,
);
});
test("typing indicator shows avatars and maintains stable name order", async ({
@@ -562,14 +584,14 @@ test("typing indicator shows avatars and maintains stable name order", async ({
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await waitForMockLiveSubscription(page, "random", KIND_TYPING_INDICATOR);
// Alice starts typing first
await page.evaluate((pubkey) => {
window.__SPROUT_E2E_EMIT_MOCK_TYPING__?.({
channelName: "general",
channelName: "random",
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
@@ -588,7 +610,7 @@ test("typing indicator shows avatars and maintains stable name order", async ({
// Bob starts typing second
await page.evaluate((pubkey) => {
window.__SPROUT_E2E_EMIT_MOCK_TYPING__?.({
channelName: "general",
channelName: "random",
pubkey,
});
}, TEST_IDENTITIES.bob.pubkey);
@@ -601,7 +623,7 @@ test("typing indicator shows avatars and maintains stable name order", async ({
// Alice re-broadcasts — order should stay "alice and bob", not flip
await page.evaluate((pubkey) => {
window.__SPROUT_E2E_EMIT_MOCK_TYPING__?.({
channelName: "general",
channelName: "random",
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
@@ -613,7 +635,7 @@ test("typing indicator shows avatars and maintains stable name order", async ({
// Bob re-broadcasts — order should still stay "alice and bob"
await page.evaluate((pubkey) => {
window.__SPROUT_E2E_EMIT_MOCK_TYPING__?.({
channelName: "general",
channelName: "random",
pubkey,
});
}, TEST_IDENTITIES.bob.pubkey);