mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Cursor
Wes
parent
bda98da771
commit
aad564b130
@@ -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()];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user