Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 4 additions & 132 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
MessageTimeline,
type MessageTimelineHandle,
} from "@/features/messages/ui/MessageTimeline";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import { buildDirectMessageIntro } from "@/features/channels/lib/dmParticipantDisplay";
import {
getDmHuddleMemberPubkeys,
Expand All @@ -23,20 +22,12 @@ import {
} from "@/features/messages/lib/videoReviewContext";
import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding";
import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping";
import {
type ProfilePanelTab,
type ProfilePanelView,
UserProfilePanel,
} from "@/features/profile/ui/UserProfilePanel";
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar";
import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import {
BotActivityComposerAction,
type BotActivityAgent,
} from "@/features/channels/ui/BotActivityBar";
import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
import {
containsWelcomePersonaMention,
WelcomeComposerBanner,
Expand All @@ -52,136 +43,17 @@ import {
isWelcomeSetupSystemMessage,
mentionsKnownAgent,
} from "@/features/channels/ui/ChannelPane.helpers";
import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types";
import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection";
import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions";
import { Button } from "@/shared/ui/button";
import type { useChannelFind } from "@/features/search/useChannelFind";
import {
buildMainTimelineEntries,
type MainTimelineEntry,
} from "@/features/messages/lib/threadPanel";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { isWelcomeChannel } from "@/features/onboarding/welcome";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
import type { Channel } from "@/shared/api/types";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
type ChannelPaneProps = {
activeChannel: Channel | null;
activityAgents?: BotActivityAgent[];
agentPubkeys?: ReadonlySet<string>;
agentPubkeysPending?: boolean;
agentSessionAgents: ChannelAgentSessionAgent[];
botTypingEntries: TypingIndicatorEntry[];
channelFind: ReturnType<typeof useChannelFind>;
channelManagementOpen?: boolean;
currentPubkey?: string;
editTarget?: {
author: string;
body: string;
id: string;
imetaMedia?: ImetaMedia[];
} | null;
fetchOlder?: () => Promise<void>;
header?: React.ReactNode;
hasOlderMessages?: boolean;
isFetchingOlder?: boolean;
isJoining?: boolean;
isSinglePanelView?: boolean;
isSending: boolean;
isTimelineLoading: boolean;
messages: TimelineMessage[];
firstUnreadMessageId?: string | null;
unreadCount?: number;
canResetThreadPanelWidth: boolean;
onCancelEdit?: () => void;
onCancelThreadReply: () => void;
onCloseAgentSession: () => void;
onCloseChannelManagement?: () => void;
onChannelManagementDeleted?: () => void;
onCloseProfilePanel: () => void;
onAddAgent?: () => void;
onCreateChannel?: () => void;
onCloseThread: () => void;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onEditSave?: (content: string, mediaTags?: string[][]) => Promise<void>;
onMarkUnread?: (message: TimelineMessage) => void;
onMarkRead?: (message: TimelineMessage) => void;
onExpandThreadReplies: (message: TimelineMessage) => void;
onJoinChannel?: () => Promise<void>;
onOpenAgentSession: (pubkey: string) => void;
onOpenDm?: (pubkeys: string[]) => Promise<void> | void;
onOpenMembers?: () => void;
onOpenProfilePanel: (pubkey: string) => void;
onOpenThread: (message: TimelineMessage) => void;
onResetThreadPanelWidth: () => void;
onSelectThreadReplyTarget: (message: TimelineMessage) => void;
onSendMessage: (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => Promise<void>;
onSendVideoReviewComment?: (
message: TimelineMessage,
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
parentEventId?: string,
) => Promise<void>;
onSendThreadReply: (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => Promise<void>;
onTargetReached?: (messageId: string) => void;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
onThreadScrollTargetResolved: () => void;
onThreadPanelResizeStart: (
event: React.PointerEvent<HTMLButtonElement>,
) => void;
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
openThreadHeadId: string | null;
shouldShowThreadSkeleton: boolean;
openAgentSessionPubkey: string | null;
onProfilePanelViewChange: (
view: ProfilePanelView,
options?: { replace?: boolean },
) => void;
onProfilePanelTabChange: (
tab: ProfilePanelTab,
options?: { replace?: boolean },
) => void;
profilePanelPubkey?: string | null;
profilePanelTab: ProfilePanelTab;
profilePanelView: ProfilePanelView;
threadHeadMessage: TimelineMessage | null;
threadMessages: MainTimelineEntry[];
threadPanelWidthPx: number;
threadTypingPubkeys: string[];
threadReplyTargetMessage: TimelineMessage | null;
threadScrollTargetId: string | null;
threadUnreadCounts?: ReadonlyMap<string, number>;
threadReplyUnreadCounts?: ReadonlyMap<string, number>;
threadFirstUnreadReplyId?: string | null;
targetMessageId: string | null;
typingPubkeys: string[];
isFollowingThread?: boolean;
onFollowThread?: () => void;
onUnfollowThread?: () => void;
followThreadById?: (rootId: string) => void;
unfollowThreadById?: (rootId: string) => void;
isFollowingThreadById?: (rootId: string) => boolean;
isMessageUnreadById?: (messageId: string) => boolean;
};
export const ChannelPane = React.memo(function ChannelPane({
activeChannel,
agentPubkeys,
Expand Down
128 changes: 128 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type * as React from "react";
import type { useChannelFind } from "@/features/search/useChannelFind";
import type {
ProfilePanelTab,
ProfilePanelView,
} from "@/features/profile/ui/UserProfilePanel";
import type { BotActivityAgent } from "@/features/channels/ui/BotActivityBar";
import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types";
import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { Channel } from "@/shared/api/types";

export type ChannelPaneProps = {
activeChannel: Channel | null;
activityAgents?: BotActivityAgent[];
agentPubkeys?: ReadonlySet<string>;
agentPubkeysPending?: boolean;
agentSessionAgents: ChannelAgentSessionAgent[];
botTypingEntries: TypingIndicatorEntry[];
channelFind: ReturnType<typeof useChannelFind>;
channelManagementOpen?: boolean;
currentPubkey?: string;
editTarget?: {
author: string;
body: string;
id: string;
imetaMedia?: ImetaMedia[];
} | null;
fetchOlder?: () => Promise<void>;
header?: React.ReactNode;
hasOlderMessages?: boolean;
isFetchingOlder?: boolean;
isJoining?: boolean;
isSinglePanelView?: boolean;
isSending: boolean;
isTimelineLoading: boolean;
messages: TimelineMessage[];
firstUnreadMessageId?: string | null;
unreadCount?: number;
canResetThreadPanelWidth: boolean;
onCancelEdit?: () => void;
onCancelThreadReply: () => void;
onCloseAgentSession: () => void;
onCloseChannelManagement?: () => void;
onChannelManagementDeleted?: () => void;
onCloseProfilePanel: () => void;
onAddAgent?: () => void;
onCreateChannel?: () => void;
onCloseThread: () => void;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onEditSave?: (content: string, mediaTags?: string[][]) => Promise<void>;
onMarkUnread?: (message: TimelineMessage) => void;
onMarkRead?: (message: TimelineMessage) => void;
onExpandThreadReplies: (message: TimelineMessage) => void;
onJoinChannel?: () => Promise<void>;
onOpenAgentSession: (pubkey: string) => void;
onOpenDm?: (pubkeys: string[]) => Promise<void> | void;
onOpenMembers?: () => void;
onOpenProfilePanel: (pubkey: string) => void;
onOpenThread: (message: TimelineMessage) => void;
onResetThreadPanelWidth: () => void;
onSelectThreadReplyTarget: (message: TimelineMessage) => void;
onSendMessage: (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => Promise<void>;
onSendVideoReviewComment?: (
message: TimelineMessage,
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
parentEventId?: string,
) => Promise<void>;
onSendThreadReply: (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => Promise<void>;
onTargetReached?: (messageId: string) => void;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
onThreadScrollTargetResolved: () => void;
onThreadPanelResizeStart: (
event: React.PointerEvent<HTMLButtonElement>,
) => void;
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
openThreadHeadId: string | null;
shouldShowThreadSkeleton: boolean;
openAgentSessionPubkey: string | null;
onProfilePanelViewChange: (
view: ProfilePanelView,
options?: { replace?: boolean },
) => void;
onProfilePanelTabChange: (
tab: ProfilePanelTab,
options?: { replace?: boolean },
) => void;
profilePanelPubkey?: string | null;
profilePanelTab: ProfilePanelTab;
profilePanelView: ProfilePanelView;
threadHeadMessage: TimelineMessage | null;
threadMessages: MainTimelineEntry[];
threadPanelWidthPx: number;
threadTypingPubkeys: string[];
threadReplyTargetMessage: TimelineMessage | null;
threadScrollTargetId: string | null;
threadUnreadCounts?: ReadonlyMap<string, number>;
threadReplyUnreadCounts?: ReadonlyMap<string, number>;
threadFirstUnreadReplyId?: string | null;
targetMessageId: string | null;
typingPubkeys: string[];
isFollowingThread?: boolean;
onFollowThread?: () => void;
onUnfollowThread?: () => void;
followThreadById?: (rootId: string) => void;
unfollowThreadById?: (rootId: string) => void;
isFollowingThreadById?: (rootId: string) => boolean;
isMessageUnreadById?: (messageId: string) => boolean;
};
4 changes: 3 additions & 1 deletion desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,9 @@ export function ChannelScreen({
(channelMembersQuery.isPending ||
managedAgentsQuery.isPending ||
relayAgentsQuery.isPending ||
(messageProfilePubkeys.length > 0 && messageProfilesQuery.isPending));
(messageProfilePubkeys.length > 0 &&
(messageProfilesQuery.isPending ||
messageProfilesQuery.isPlaceholderData)));
const {
agentSessionCandidates,
botTypingEntries,
Expand Down
8 changes: 8 additions & 0 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ type E2eConfig = {
canvasReadError?: string;
openDmDelayMs?: number;
sendMessageDelayMs?: number;
usersBatchDelayMs?: number;
/** Delay (ms) applied to older-history (`history-` subId) fetches so e2e
* tests can observe the in-flight prepend window. 0/undefined = instant. */
historyDelayMs?: number;
Expand Down Expand Up @@ -3373,6 +3374,13 @@ async function handleGetUsersBatch(
},
config: E2eConfig | undefined,
) {
const usersBatchDelayMs = config?.mock?.usersBatchDelayMs ?? 0;
if (usersBatchDelayMs > 0) {
await new Promise<void>((resolve) => {
window.setTimeout(resolve, usersBatchDelayMs);
});
}

const identity = getIdentity(config);
if (!identity) {
const profiles: RawUsersBatchResponse["profiles"] = {};
Expand Down
61 changes: 61 additions & 0 deletions desktop/tests/e2e/mentions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1460,6 +1460,67 @@ test("wave attachment huddle passes the bot DM pubkey", async ({ page }) => {
.toEqual(expect.arrayContaining([TEST_IDENTITIES.charlie.pubkey]));
});

test("wave attachment huddle waits for placeholder profile-only bot data", async ({
page,
}) => {
await installMockBridge(page, { usersBatchDelayMs: 2_000 });

await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general", SYSTEM_MESSAGE_KIND);

await page.evaluate(
({ kind, targetPubkey }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: JSON.stringify({
type: "member_joined",
actor: targetPubkey,
target: targetPubkey,
}),
kind,
});
},
{
kind: SYSTEM_MESSAGE_KIND,
targetPubkey: PROFILE_ONLY_AGENT_PUBKEY,
},
);
await waitForTimelineSettled(page);

const joinedRow = page
.getByTestId("system-message-row")
.filter({ hasText: "joined the channel" });
const agentChip = joinedRow.locator(
"[data-mention].agent-mention-highlight",
{
hasText: "mira",
},
);
await expect(agentChip).toBeVisible({ timeout: 5_000 });
await agentChip.hover();

const profilePopover = page.locator(
'[data-testid="user-profile-popover"][data-state="open"]',
);
await expect(profilePopover).toBeVisible();
await profilePopover
.getByTestId(`user-profile-popover-wave-${PROFILE_ONLY_AGENT_PUBKEY}`)
.click();

const startHuddleButton = page
.getByTestId("message-wave-attachment")
.getByRole("button", { name: "Start huddle" });
await expect(startHuddleButton).toBeDisabled();
await expect(startHuddleButton).toBeEnabled({ timeout: 5_000 });
await startHuddleButton.click();

await expect
.poll(() => readStartHuddleMemberPubkeys(page))
.toEqual(expect.arrayContaining([PROFILE_ONLY_AGENT_PUBKEY]));
});

test("wave attachment huddle waits for delayed bot DM pubkey", async ({
page,
}) => {
Expand Down
Loading
Loading