Polish desktop typing and agents UI (#92)

This commit is contained in:
Wes
2026-03-17 12:09:02 -07:00
committed by GitHub
parent ee609c24a0
commit 6ecd04819e
13 changed files with 525 additions and 89 deletions
+17 -2
View File
@@ -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>
+9
View File
@@ -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>
+15
View File
@@ -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);
}
+1
View File
@@ -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
+44 -5
View File
@@ -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();
+44
View File
@@ -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,
}) => {