mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish desktop typing and agents UI (#92)
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
collectMessageAuthorPubkeys,
|
||||
formatTimelineMessages,
|
||||
} from "@/features/messages/lib/formatTimelineMessages";
|
||||
import { useChannelTyping } from "@/features/messages/useChannelTyping";
|
||||
import {
|
||||
getChannelIdFromTags,
|
||||
getThreadReference,
|
||||
@@ -151,8 +152,21 @@ export function AppShell() {
|
||||
() => collectMessageAuthorPubkeys(resolvedMessages),
|
||||
[resolvedMessages],
|
||||
);
|
||||
const messageProfilesQuery = useUsersBatchQuery(messageAuthorPubkeys, {
|
||||
enabled: resolvedMessages.length > 0,
|
||||
const latestMessageEvent = React.useMemo(
|
||||
() => resolvedMessages[resolvedMessages.length - 1] ?? null,
|
||||
[resolvedMessages],
|
||||
);
|
||||
const typingPubkeys = useChannelTyping(
|
||||
activeChannel,
|
||||
identityQuery.data?.pubkey,
|
||||
latestMessageEvent,
|
||||
);
|
||||
const messageProfilePubkeys = React.useMemo(
|
||||
() => [...new Set([...messageAuthorPubkeys, ...typingPubkeys])],
|
||||
[messageAuthorPubkeys, typingPubkeys],
|
||||
);
|
||||
const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, {
|
||||
enabled: messageProfilePubkeys.length > 0,
|
||||
});
|
||||
|
||||
const timelineMessages = React.useMemo(
|
||||
@@ -638,6 +652,7 @@ export function AppShell() {
|
||||
? searchAnchor.eventId
|
||||
: null
|
||||
}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
|
||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||
import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
|
||||
import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
@@ -29,6 +30,7 @@ type ChannelPaneProps = {
|
||||
replyTargetId: string | null;
|
||||
replyTargetMessage: TimelineMessage | null;
|
||||
targetMessageId: string | null;
|
||||
typingPubkeys: string[];
|
||||
};
|
||||
|
||||
export function ChannelPane({
|
||||
@@ -46,6 +48,7 @@ export function ChannelPane({
|
||||
replyTargetId,
|
||||
replyTargetMessage,
|
||||
targetMessageId,
|
||||
typingPubkeys,
|
||||
}: ChannelPaneProps) {
|
||||
return (
|
||||
<React.Fragment key={activeChannel?.id ?? "no-channel"}>
|
||||
@@ -72,6 +75,12 @@ export function ChannelPane({
|
||||
onToggleReaction={onToggleReaction}
|
||||
targetMessageId={targetMessageId}
|
||||
/>
|
||||
<TypingIndicatorRow
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
<MessageComposer
|
||||
channelId={activeChannel?.id ?? null}
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
|
||||
@@ -184,7 +184,7 @@ export function AgentsView() {
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4 sm:px-6">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr,0.9fr]">
|
||||
<div className="flex flex-col gap-6">
|
||||
<ManagedAgentsSection
|
||||
actionErrorMessage={actionErrorMessage}
|
||||
actionNoticeMessage={actionNoticeMessage}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { RelayAgent } from "@/shared/api/types";
|
||||
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
|
||||
import { truncatePubkey } from "./agentUi";
|
||||
|
||||
export function RelayAgentCard({
|
||||
agent,
|
||||
isManagedLocally,
|
||||
}: {
|
||||
agent: RelayAgent;
|
||||
isManagedLocally: boolean;
|
||||
}) {
|
||||
const visibleCapabilities = agent.capabilities.slice(0, 4);
|
||||
const hiddenCapabilityCount =
|
||||
agent.capabilities.length - visibleCapabilities.length;
|
||||
|
||||
return (
|
||||
<article className="rounded-3xl border border-border/70 bg-card/80 p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold tracking-tight">
|
||||
{agent.name}
|
||||
</h3>
|
||||
{isManagedLocally ? (
|
||||
<span className="rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary-foreground">
|
||||
Local
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{truncatePubkey(agent.pubkey)}
|
||||
{agent.agentType ? ` · ${agent.agentType}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<PresenceBadge status={agent.status} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{visibleCapabilities.map((capability) => (
|
||||
<span
|
||||
className="rounded-full border border-border/70 bg-background/70 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground"
|
||||
key={capability}
|
||||
>
|
||||
{capability}
|
||||
</span>
|
||||
))}
|
||||
{hiddenCapabilityCount > 0 ? (
|
||||
<span className="rounded-full border border-border/70 bg-background/70 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
+{hiddenCapabilityCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
{agent.channels.length > 0
|
||||
? `Visible in ${agent.channels.join(", ")}`
|
||||
: "No visible channel memberships yet."}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RelayAgent } from "@/shared/api/types";
|
||||
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { RelayAgentCard } from "./RelayAgentCard";
|
||||
import { truncatePubkey } from "./agentUi";
|
||||
|
||||
export function RelayDirectorySection({
|
||||
error,
|
||||
@@ -13,6 +14,17 @@ export function RelayDirectorySection({
|
||||
managedPubkeys: Set<string>;
|
||||
relayAgents: RelayAgent[];
|
||||
}) {
|
||||
const sortedAgents = [...relayAgents].sort((left, right) => {
|
||||
const leftManaged = managedPubkeys.has(left.pubkey);
|
||||
const rightManaged = managedPubkeys.has(right.pubkey);
|
||||
|
||||
if (leftManaged !== rightManaged) {
|
||||
return leftManaged ? -1 : 1;
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
@@ -25,17 +37,24 @@ export function RelayDirectorySection({
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-3">
|
||||
{["directory-1", "directory-2"].map((key) => (
|
||||
<div
|
||||
className="rounded-3xl border border-border/70 bg-card/80 p-4"
|
||||
key={key}
|
||||
>
|
||||
<Skeleton className="h-5 w-36" />
|
||||
<Skeleton className="mt-3 h-4 w-44" />
|
||||
<Skeleton className="mt-4 h-12 w-full" />
|
||||
</div>
|
||||
))}
|
||||
<div className="overflow-hidden rounded-3xl border border-border/70 bg-card/80 shadow-sm">
|
||||
<div className="grid gap-0">
|
||||
{["directory-1", "directory-2", "directory-3"].map((key) => (
|
||||
<div
|
||||
className="grid grid-cols-[minmax(0,2fr)_auto_minmax(0,1fr)_minmax(0,1.6fr)_auto] items-center gap-4 border-b border-border/60 px-4 py-3 last:border-b-0"
|
||||
key={key}
|
||||
>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-5 w-12 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -51,13 +70,70 @@ export function RelayDirectorySection({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{relayAgents.map((agent) => (
|
||||
<RelayAgentCard
|
||||
agent={agent}
|
||||
isManagedLocally={managedPubkeys.has(agent.pubkey)}
|
||||
key={agent.pubkey}
|
||||
/>
|
||||
))}
|
||||
{!isLoading && relayAgents.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-3xl border border-border/70 bg-card/80 shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table
|
||||
className="w-full border-collapse text-left text-sm"
|
||||
data-testid="relay-directory-table"
|
||||
>
|
||||
<thead className="bg-muted/35 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Agent</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Type</th>
|
||||
<th className="px-4 py-3">Channels</th>
|
||||
<th className="px-4 py-3">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedAgents.map((agent) => {
|
||||
const isManagedLocally = managedPubkeys.has(agent.pubkey);
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="border-b border-border/60 last:border-b-0"
|
||||
key={agent.pubkey}
|
||||
>
|
||||
<td className="min-w-[16rem] px-4 py-3 align-top">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{agent.name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{truncatePubkey(agent.pubkey)}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<PresenceBadge
|
||||
className="px-2.5 py-0.5 text-[11px]"
|
||||
status={agent.status}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top text-muted-foreground">
|
||||
{agent.agentType || "Unknown"}
|
||||
</td>
|
||||
<td className="max-w-[20rem] px-4 py-3 align-top text-muted-foreground">
|
||||
<span className="block truncate">
|
||||
{agent.channels.length > 0
|
||||
? agent.channels.join(", ")
|
||||
: "No visible channel memberships"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<span className="inline-flex rounded-full border border-border/70 bg-background/70 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
{isManagedLocally ? "Local" : "Relay"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
|
||||
@@ -231,7 +231,7 @@ export function MessageRow({
|
||||
</div>
|
||||
{renderBody()}
|
||||
{reactions.length > 0 ? (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{reactions.map((reaction: TimelineReaction) => (
|
||||
<button
|
||||
aria-label={`Toggle ${reaction.emoji} reaction`}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
|
||||
type TypingIndicatorRowProps = {
|
||||
channel: Channel | null;
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
typingPubkeys: string[];
|
||||
};
|
||||
|
||||
function resolveFallbackName(channel: Channel | null, pubkey: string) {
|
||||
if (!channel || channel.channelType !== "dm") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const participantIndex = channel.participantPubkeys.findIndex(
|
||||
(candidate) => candidate.toLowerCase() === pubkey.toLowerCase(),
|
||||
);
|
||||
|
||||
if (participantIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return channel.participants[participantIndex] ?? null;
|
||||
}
|
||||
|
||||
function formatTypingLabel(names: string[]) {
|
||||
if (names.length === 1) {
|
||||
return `${names[0]} is typing...`;
|
||||
}
|
||||
|
||||
if (names.length === 2) {
|
||||
return `${names[0]} and ${names[1]} are typing...`;
|
||||
}
|
||||
|
||||
if (names.length === 3) {
|
||||
return `${names[0]}, ${names[1]}, and ${names[2]} are typing...`;
|
||||
}
|
||||
|
||||
return `${names[0]}, ${names[1]}, and ${names.length - 2} others are typing...`;
|
||||
}
|
||||
|
||||
export function TypingIndicatorRow({
|
||||
channel,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
typingPubkeys,
|
||||
}: TypingIndicatorRowProps) {
|
||||
const labels = React.useMemo(
|
||||
() =>
|
||||
typingPubkeys.map((pubkey) =>
|
||||
resolveUserLabel({
|
||||
pubkey,
|
||||
currentPubkey,
|
||||
fallbackName: resolveFallbackName(channel, pubkey),
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
}),
|
||||
),
|
||||
[channel, currentPubkey, profiles, typingPubkeys],
|
||||
);
|
||||
|
||||
if (labels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="bg-background/95 px-4 py-2 sm:px-6"
|
||||
data-testid="message-typing-indicator"
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-4xl items-center">
|
||||
<p
|
||||
className="truncate text-sm text-muted-foreground"
|
||||
data-testid="message-typing-indicator-label"
|
||||
>
|
||||
{formatTypingLabel(labels)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { getChannelIdFromTags } from "@/features/messages/lib/threading";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import type { Channel, RelayEvent } from "@/shared/api/types";
|
||||
import {
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_DIFF,
|
||||
KIND_STREAM_MESSAGE_EDIT,
|
||||
KIND_STREAM_MESSAGE_V2,
|
||||
KIND_TYPING_INDICATOR,
|
||||
} from "@/shared/constants/kinds";
|
||||
|
||||
type TypingState = Record<string, number>;
|
||||
|
||||
const TYPING_INDICATOR_TTL_MS = 5_500;
|
||||
const TYPING_PRUNE_INTERVAL_MS = 1_000;
|
||||
const TYPING_POST_MESSAGE_SUPPRESS_MS = 4_000;
|
||||
|
||||
function pruneTypingState(state: TypingState, now = Date.now()) {
|
||||
let changed = false;
|
||||
const next: TypingState = {};
|
||||
|
||||
for (const [pubkey, expiresAt] of Object.entries(state)) {
|
||||
if (expiresAt > now) {
|
||||
next[pubkey] = expiresAt;
|
||||
continue;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? next : state;
|
||||
}
|
||||
|
||||
function isTypingCompletionEvent(event: RelayEvent | null | undefined) {
|
||||
if (!event) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
event.kind === KIND_STREAM_MESSAGE ||
|
||||
event.kind === KIND_STREAM_MESSAGE_V2 ||
|
||||
event.kind === KIND_STREAM_MESSAGE_EDIT ||
|
||||
event.kind === KIND_STREAM_MESSAGE_DIFF
|
||||
);
|
||||
}
|
||||
|
||||
export function useChannelTyping(
|
||||
channel: Channel | null,
|
||||
currentPubkey?: string,
|
||||
latestMessageEvent?: RelayEvent | null,
|
||||
) {
|
||||
const channelId = channel?.id ?? null;
|
||||
const channelType = channel?.channelType ?? null;
|
||||
const [typingByPubkey, setTypingByPubkey] = useState<TypingState>({});
|
||||
const normalizedCurrentPubkey = currentPubkey?.toLowerCase();
|
||||
const typingSuppressUntilByPubkeyRef = useRef<Record<string, number>>({});
|
||||
const latestMessageCreatedAtByPubkeyRef = useRef<Record<string, number>>({});
|
||||
|
||||
const registerTyping = useEffectEvent((event: RelayEvent) => {
|
||||
if (!channelId || event.kind !== KIND_TYPING_INDICATOR) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getChannelIdFromTags(event.tags) !== channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const typingPubkey = event.pubkey.toLowerCase();
|
||||
if (normalizedCurrentPubkey && typingPubkey === normalizedCurrentPubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const suppressUntil =
|
||||
typingSuppressUntilByPubkeyRef.current[typingPubkey] ?? 0;
|
||||
if (suppressUntil > Date.now()) {
|
||||
return;
|
||||
}
|
||||
if (suppressUntil > 0) {
|
||||
delete typingSuppressUntilByPubkeyRef.current[typingPubkey];
|
||||
}
|
||||
|
||||
const latestMessageCreatedAt =
|
||||
latestMessageCreatedAtByPubkeyRef.current[typingPubkey] ?? 0;
|
||||
if (event.created_at <= latestMessageCreatedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTypingByPubkey((current) => ({
|
||||
...pruneTypingState(current),
|
||||
[typingPubkey]: Date.now() + TYPING_INDICATOR_TTL_MS,
|
||||
}));
|
||||
});
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: channel changes should clear local typing state
|
||||
useEffect(() => {
|
||||
setTypingByPubkey({});
|
||||
typingSuppressUntilByPubkeyRef.current = {};
|
||||
latestMessageCreatedAtByPubkeyRef.current = {};
|
||||
}, [channelId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!channelId ||
|
||||
!latestMessageEvent ||
|
||||
!isTypingCompletionEvent(latestMessageEvent)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getChannelIdFromTags(latestMessageEvent.tags) !== channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authorPubkey = latestMessageEvent.pubkey.toLowerCase();
|
||||
latestMessageCreatedAtByPubkeyRef.current[authorPubkey] = Math.max(
|
||||
latestMessageCreatedAtByPubkeyRef.current[authorPubkey] ?? 0,
|
||||
latestMessageEvent.created_at,
|
||||
);
|
||||
typingSuppressUntilByPubkeyRef.current[authorPubkey] =
|
||||
Date.now() + TYPING_POST_MESSAGE_SUPPRESS_MS;
|
||||
setTypingByPubkey((current) => {
|
||||
const next = pruneTypingState(current);
|
||||
if (!(authorPubkey in next)) {
|
||||
return next;
|
||||
}
|
||||
|
||||
const updated = { ...next };
|
||||
delete updated[authorPubkey];
|
||||
return updated;
|
||||
});
|
||||
}, [channelId, latestMessageEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!channelId || channelType === "forum") {
|
||||
return;
|
||||
}
|
||||
|
||||
let isDisposed = false;
|
||||
let cleanup: (() => Promise<void>) | undefined;
|
||||
|
||||
relayClient
|
||||
.subscribeToTypingIndicators(channelId, (event) => {
|
||||
if (!isDisposed) {
|
||||
registerTyping(event);
|
||||
}
|
||||
})
|
||||
.then((dispose) => {
|
||||
if (isDisposed) {
|
||||
void dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup = dispose;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"Failed to subscribe to typing indicators",
|
||||
channelId,
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
if (cleanup) {
|
||||
void cleanup();
|
||||
}
|
||||
};
|
||||
}, [channelId, channelType]);
|
||||
|
||||
const hasActiveTypers = Object.keys(typingByPubkey).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveTypers) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
setTypingByPubkey((current) => pruneTypingState(current));
|
||||
}, TYPING_PRUNE_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [hasActiveTypers]);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
Object.entries(typingByPubkey)
|
||||
.sort((left, right) => right[1] - left[1])
|
||||
.map(([pubkey]) => pubkey),
|
||||
[typingByPubkey],
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useManagedAgentsQuery } from "@/features/agents/hooks";
|
||||
import { getPresenceLabel } from "@/features/presence/lib/presence";
|
||||
import { usePresenceQuery } from "@/features/presence/hooks";
|
||||
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
@@ -357,6 +359,10 @@ export function AppSidebar({
|
||||
) satisfies Record<string, PresenceStatus>,
|
||||
[currentPubkey, directMessages, dmPresenceQuery.data],
|
||||
);
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const totalAgentCount = managedAgentsQuery.data?.length ?? 0;
|
||||
const shouldShowAgentCount =
|
||||
totalAgentCount > 0 || !managedAgentsQuery.isLoading;
|
||||
const resolvedDisplayName =
|
||||
profile?.displayName?.trim() ||
|
||||
fallbackDisplayName?.trim() ||
|
||||
@@ -458,6 +464,14 @@ export function AppSidebar({
|
||||
<Bot className="h-4 w-4" />
|
||||
<span>Agents</span>
|
||||
</SidebarMenuButton>
|
||||
{shouldShowAgentCount ? (
|
||||
<SidebarMenuBadge
|
||||
className="right-2 rounded-full bg-sidebar-accent/70 px-1.5 text-[11px] text-sidebar-foreground/75 peer-data-[active=true]/menu-button:bg-sidebar-primary-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-primary-foreground"
|
||||
data-testid="sidebar-agents-count"
|
||||
>
|
||||
{totalAgentCount}
|
||||
</SidebarMenuBadge>
|
||||
) : null}
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { PresenceStatus, RelayEvent } from "@/shared/api/types";
|
||||
import {
|
||||
CHANNEL_EVENT_KINDS,
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_TYPING_INDICATOR,
|
||||
} from "@/shared/constants/kinds";
|
||||
import {
|
||||
getTextPayload,
|
||||
@@ -118,6 +119,20 @@ class RelayClient {
|
||||
return this.subscribe(this.buildChannelFilter(channelId, 50), onEvent);
|
||||
}
|
||||
|
||||
async subscribeToTypingIndicators(
|
||||
channelId: string,
|
||||
onEvent: (event: RelayEvent) => void,
|
||||
) {
|
||||
return this.subscribe(
|
||||
{
|
||||
kinds: [KIND_TYPING_INDICATOR],
|
||||
"#h": [channelId],
|
||||
limit: 10,
|
||||
},
|
||||
onEvent,
|
||||
);
|
||||
}
|
||||
|
||||
async subscribeToAllStreamMessages(onEvent: (event: RelayEvent) => void) {
|
||||
return this.subscribe(this.buildGlobalStreamFilter(50), onEvent);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export const KIND_STREAM_MESSAGE_V2 = 40002;
|
||||
export const KIND_STREAM_MESSAGE_EDIT = 40003;
|
||||
export const KIND_STREAM_MESSAGE_DIFF = 40008;
|
||||
export const KIND_SYSTEM_MESSAGE = 40099;
|
||||
export const KIND_TYPING_INDICATOR = 20002;
|
||||
|
||||
export const CHANNEL_EVENT_KINDS = [
|
||||
KIND_DELETION, // 5 — NIP-09 event deletions
|
||||
|
||||
@@ -296,6 +296,11 @@ declare global {
|
||||
__SPROUT_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
||||
channelName: string;
|
||||
content: string;
|
||||
pubkey?: string;
|
||||
}) => RelayEvent;
|
||||
__SPROUT_E2E_EMIT_MOCK_TYPING__?: (input: {
|
||||
channelName: string;
|
||||
pubkey?: string;
|
||||
}) => RelayEvent;
|
||||
}
|
||||
}
|
||||
@@ -1098,21 +1103,41 @@ function recordMockMessage(channelId: string, event: RelayEvent) {
|
||||
touchMockChannel(channel);
|
||||
}
|
||||
|
||||
function emitMockChannelMessage(channelId: string, content: string) {
|
||||
const event = createMockEvent(9, content, [["h", channelId]]);
|
||||
function emitMockChannelMessage(
|
||||
channelId: string,
|
||||
content: string,
|
||||
pubkey?: string,
|
||||
) {
|
||||
const event = createMockEvent(9, content, [["h", channelId]], pubkey);
|
||||
recordMockMessage(channelId, event);
|
||||
emitMockLiveEvent(channelId, event);
|
||||
return event;
|
||||
}
|
||||
|
||||
function emitMockTypingIndicator(channelId: string, pubkey: string) {
|
||||
const event: RelayEvent = {
|
||||
id: crypto.randomUUID().replace(/-/g, ""),
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 20002,
|
||||
tags: [["h", channelId]],
|
||||
content: "",
|
||||
sig: "mocksig".repeat(20).slice(0, 128),
|
||||
};
|
||||
|
||||
emitMockLiveEvent(channelId, event);
|
||||
return event;
|
||||
}
|
||||
|
||||
function createMockEvent(
|
||||
kind: number,
|
||||
content: string,
|
||||
tags: string[][],
|
||||
pubkey = DEFAULT_MOCK_IDENTITY.pubkey,
|
||||
): RelayEvent {
|
||||
return {
|
||||
id: crypto.randomUUID().replace(/-/g, ""),
|
||||
pubkey: DEFAULT_MOCK_IDENTITY.pubkey,
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind,
|
||||
tags,
|
||||
@@ -2705,7 +2730,11 @@ export function maybeInstallE2eTauriMocks() {
|
||||
resetMockManagedAgents();
|
||||
mockWindows("main");
|
||||
window.__SPROUT_E2E_COMMANDS__ = [];
|
||||
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__ = ({ channelName, content }) => {
|
||||
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__ = ({
|
||||
channelName,
|
||||
content,
|
||||
pubkey,
|
||||
}) => {
|
||||
const channel = mockChannels.find(
|
||||
(candidate) => candidate.name === channelName,
|
||||
);
|
||||
@@ -2713,7 +2742,17 @@ export function maybeInstallE2eTauriMocks() {
|
||||
throw new Error(`Mock channel ${channelName} not found.`);
|
||||
}
|
||||
|
||||
return emitMockChannelMessage(channel.id, content);
|
||||
return emitMockChannelMessage(channel.id, content, pubkey);
|
||||
};
|
||||
window.__SPROUT_E2E_EMIT_MOCK_TYPING__ = ({ channelName, pubkey }) => {
|
||||
const channel = mockChannels.find(
|
||||
(candidate) => candidate.name === channelName,
|
||||
);
|
||||
if (!channel) {
|
||||
throw new Error(`Mock channel ${channelName} not found.`);
|
||||
}
|
||||
|
||||
return emitMockTypingIndicator(channel.id, pubkey ?? CHARLIE_PUBKEY);
|
||||
};
|
||||
mockIPC(async (command, payload) => {
|
||||
const activeConfig = getConfig();
|
||||
|
||||
@@ -27,6 +27,7 @@ test("sidebar shows all channel types", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-agents-count")).toHaveText("0");
|
||||
|
||||
// Streams
|
||||
const streamList = page.getByTestId("stream-list");
|
||||
@@ -149,6 +150,49 @@ test("channel with messages shows content", async ({ page }) => {
|
||||
);
|
||||
});
|
||||
|
||||
test("shows and clears typing indicators for active channel bots", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("channel-agents").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("agents");
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await page.evaluate((pubkey) => {
|
||||
window.__SPROUT_E2E_EMIT_MOCK_TYPING__?.({
|
||||
channelName: "agents",
|
||||
pubkey,
|
||||
});
|
||||
}, TEST_IDENTITIES.charlie.pubkey);
|
||||
|
||||
await expect(page.getByTestId("message-typing-indicator")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("message-typing-indicator-label"),
|
||||
).toContainText("charlie is typing");
|
||||
|
||||
await page.evaluate((pubkey) => {
|
||||
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "agents",
|
||||
content: "Done.",
|
||||
pubkey,
|
||||
});
|
||||
}, TEST_IDENTITIES.charlie.pubkey);
|
||||
|
||||
await expect(page.getByTestId("message-timeline")).toContainText("Done.");
|
||||
await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0);
|
||||
|
||||
await page.waitForTimeout(1_200);
|
||||
await page.evaluate((pubkey) => {
|
||||
window.__SPROUT_E2E_EMIT_MOCK_TYPING__?.({
|
||||
channelName: "agents",
|
||||
pubkey,
|
||||
});
|
||||
}, TEST_IDENTITIES.charlie.pubkey);
|
||||
|
||||
await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("sidebar shows unread indicator for newly active channels", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user