diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index b118e69e02..d23f3204cb 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -45,6 +45,7 @@ export default defineConfig({ "**/edit-agent.spec.ts", "**/doctor-cta-screenshots.spec.ts", "**/pubkey-display-screenshots.spec.ts", + "**/dm-new-message-screenshots.spec.ts", "**/file-attachment.spec.ts", "**/image-attachment-gallery.spec.ts", "**/composer-image-draw.spec.ts", diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index 166c06725d..b0ce894931 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -5,6 +5,7 @@ import type { SearchHit } from "@/shared/api/types"; export type AppView = | "home" | "channel" + | "messages" | "agents" | "workflows" | "pulse" @@ -117,6 +118,13 @@ export function deriveShellRoute(pathname: string): { }; } + if (pathname === "/messages/new") { + return { + selectedChannelId: null, + selectedView: "messages", + }; + } + if (pathname === "/agents") { return { selectedChannelId: null, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 905235aa88..f7c6c496f7 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -109,7 +109,6 @@ export function AppShell() { const [searchFocusRequest, setSearchFocusRequest] = React.useState(0); const [browseDialogType, setBrowseDialogType] = React.useState(null); - const [isNewDmOpen, setIsNewDmOpen] = React.useState(false); const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false); const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false); const mainInsetRef = React.useRef(null); @@ -119,6 +118,7 @@ export function AppShell() { goAgents, goChannel, goHome, + goNewMessage, goProjects, goPulse, goSettings, @@ -547,7 +547,10 @@ export function AppShell() { // Dispatch `buzz://message` deep links into the router. useMessageDeepLinks(); - const handleOpenNewDm = React.useCallback(() => setIsNewDmOpen(true), []); + const handleOpenNewDm = React.useCallback( + () => void goNewMessage(), + [goNewMessage], + ); const handleOpenCreateChannel = React.useCallback( () => setIsCreateChannelOpen(true), [], @@ -754,8 +757,6 @@ export function AppShell() { isCreatingChannel={createChannelMutation.isPending} isCreatingForum={createForumMutation.isPending} isLoading={channelsQuery.isLoading} - isOpeningDm={openDmMutation.isPending} - isNewDmOpen={isNewDmOpen} isCreateChannelOpen={isCreateChannelOpen} isPresencePending={presenceSession.isPending} onAddWorkspace={(workspace) => { @@ -763,7 +764,7 @@ export function AppShell() { handleSwitchWorkspace(id); }} onAddWorkspaceOpenChange={setIsAddWorkspaceOpen} - onNewDmOpenChange={setIsNewDmOpen} + onNewMessage={handleOpenNewDm} onCreateChannelOpenChange={setIsCreateChannelOpen} onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)} onUpdateWorkspace={workspacesHook.updateWorkspace} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index e6ecae125e..ae2adc31e6 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -185,6 +185,17 @@ export function useAppNavigation() { [commitNavigation], ); + const goNewMessage = React.useCallback( + (behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/messages/new", + }, + behavior, + ), + [commitNavigation], + ); + const goForumPost = React.useCallback( ( channelId: string, @@ -284,6 +295,7 @@ export function useAppNavigation() { goChannel, goForumPost, goHome, + goNewMessage, goProject, goProjects, goPulse, diff --git a/desktop/src/app/routeTree.gen.ts b/desktop/src/app/routeTree.gen.ts index d38c7100af..2bc2c8ddb6 100644 --- a/desktop/src/app/routeTree.gen.ts +++ b/desktop/src/app/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as agentsRouteImport } from "./routes/agents"; import { Route as indexRouteImport } from "./routes/index"; import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId"; import { Route as projectsDotprojectIdRouteImport } from "./routes/projects.$projectId"; +import { Route as messagesDotnewRouteImport } from "./routes/messages.new"; import { Route as channelsDotchannelIdRouteImport } from "./routes/channels.$channelId"; import { Route as channelsDotchannelIdDotpostsDotpostIdRouteImport } from "./routes/channels.$channelId.posts.$postId"; @@ -62,6 +63,11 @@ const projectsDotprojectIdRoute = projectsDotprojectIdRouteImport.update({ path: "/projects/$projectId", getParentRoute: () => rootRouteImport, } as any); +const messagesDotnewRoute = messagesDotnewRouteImport.update({ + id: "/messages/new", + path: "/messages/new", + getParentRoute: () => rootRouteImport, +} as any); const channelsDotchannelIdRoute = channelsDotchannelIdRouteImport.update({ id: "/channels/$channelId", path: "/channels/$channelId", @@ -83,6 +89,7 @@ export interface FileRoutesByFullPath { "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; + "/messages/new": typeof messagesDotnewRoute; "/projects/$projectId": typeof projectsDotprojectIdRoute; "/workflows/$workflowId": typeof workflowsDotworkflowIdRoute; "/channels/$channelId/posts/$postId": typeof channelsDotchannelIdDotpostsDotpostIdRoute; @@ -96,6 +103,7 @@ export interface FileRoutesByTo { "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; + "/messages/new": typeof messagesDotnewRoute; "/projects/$projectId": typeof projectsDotprojectIdRoute; "/workflows/$workflowId": typeof workflowsDotworkflowIdRoute; "/channels/$channelId/posts/$postId": typeof channelsDotchannelIdDotpostsDotpostIdRoute; @@ -110,6 +118,7 @@ export interface FileRoutesById { "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; + "/messages/new": typeof messagesDotnewRoute; "/projects/$projectId": typeof projectsDotprojectIdRoute; "/workflows/$workflowId": typeof workflowsDotworkflowIdRoute; "/channels/$channelId/posts/$postId": typeof channelsDotchannelIdDotpostsDotpostIdRoute; @@ -125,6 +134,7 @@ export interface FileRouteTypes { | "/settings" | "/workflows" | "/channels/$channelId" + | "/messages/new" | "/projects/$projectId" | "/workflows/$workflowId" | "/channels/$channelId/posts/$postId"; @@ -138,6 +148,7 @@ export interface FileRouteTypes { | "/settings" | "/workflows" | "/channels/$channelId" + | "/messages/new" | "/projects/$projectId" | "/workflows/$workflowId" | "/channels/$channelId/posts/$postId"; @@ -151,6 +162,7 @@ export interface FileRouteTypes { | "/settings" | "/workflows" | "/channels/$channelId" + | "/messages/new" | "/projects/$projectId" | "/workflows/$workflowId" | "/channels/$channelId/posts/$postId"; @@ -165,6 +177,7 @@ export interface RootRouteChildren { settingsRoute: typeof settingsRoute; workflowsRoute: typeof workflowsRoute; channelsDotchannelIdRoute: typeof channelsDotchannelIdRoute; + messagesDotnewRoute: typeof messagesDotnewRoute; projectsDotprojectIdRoute: typeof projectsDotprojectIdRoute; workflowsDotworkflowIdRoute: typeof workflowsDotworkflowIdRoute; channelsDotchannelIdDotpostsDotpostIdRoute: typeof channelsDotchannelIdDotpostsDotpostIdRoute; @@ -235,6 +248,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof projectsDotprojectIdRouteImport; parentRoute: typeof rootRouteImport; }; + "/messages/new": { + id: "/messages/new"; + path: "/messages/new"; + fullPath: "/messages/new"; + preLoaderRoute: typeof messagesDotnewRouteImport; + parentRoute: typeof rootRouteImport; + }; "/channels/$channelId": { id: "/channels/$channelId"; path: "/channels/$channelId"; @@ -261,6 +281,7 @@ const rootRouteChildren: RootRouteChildren = { settingsRoute: settingsRoute, workflowsRoute: workflowsRoute, channelsDotchannelIdRoute: channelsDotchannelIdRoute, + messagesDotnewRoute: messagesDotnewRoute, projectsDotprojectIdRoute: projectsDotprojectIdRoute, workflowsDotworkflowIdRoute: workflowsDotworkflowIdRoute, channelsDotchannelIdDotpostsDotpostIdRoute: diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index 5dc4789ae6..f5c6938e11 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -10,6 +10,7 @@ export const routes = rootRoute("root.tsx", [ route("/workflows/$workflowId", "workflows.$workflowId.tsx"), route("/projects", "projects.tsx"), route("/projects/$projectId", "projects.$projectId.tsx"), + route("/messages/new", "messages.new.tsx"), route("/channels/$channelId", "channels.$channelId.tsx"), route( "/channels/$channelId/posts/$postId", diff --git a/desktop/src/app/routes/messages.new.tsx b/desktop/src/app/routes/messages.new.tsx new file mode 100644 index 0000000000..dbb5497e55 --- /dev/null +++ b/desktop/src/app/routes/messages.new.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { NewMessageScreen } from "@/features/messages/ui/NewMessageScreen"; + +export const Route = createFileRoute("/messages/new")({ + component: NewMessageScreen, +}); diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 18b610d466..5325126a54 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -84,6 +84,12 @@ export type CreateChannelManagedAgentResult = runtimeId: string; }; +export type ProvisionChannelManagedAgentResult = { + agent: ManagedAgent; + created: boolean; + runtimeId: string; +}; + export type CreateChannelManagedAgentBatchFailure = { kind: "generic" | "persona"; name: string; @@ -238,16 +244,13 @@ export async function ensureChannelAgentPresetInChannel( }; } -export async function createChannelManagedAgent( - channelId: string, +export async function provisionChannelManagedAgent( input: CreateChannelManagedAgentInput, context?: { managedAgents?: ManagedAgent[]; channelMemberPubkeys?: ReadonlySet; }, -): Promise { - const role = input.role ?? "bot"; - const ensureRunning = input.ensureRunning ?? true; +): Promise { const trimmedName = input.name.trim(); if (trimmedName.length === 0) { @@ -285,13 +288,8 @@ export async function createChannelManagedAgent( ).agent : reusable; - const attached = await attachManagedAgentToChannel(channelId, { - agent: updatedAgent, - role, - ensureRunning, - }); return { - ...attached, + agent: updatedAgent, created: false, runtimeId: input.runtime.id, }; @@ -328,13 +326,8 @@ export async function createChannelManagedAgent( ).agent : reusable; - const attached = await attachManagedAgentToChannel(channelId, { - agent: updatedAgent, - role, - ensureRunning, - }); return { - ...attached, + agent: updatedAgent, created: false, runtimeId: input.runtime.id, }; @@ -373,16 +366,32 @@ export async function createChannelManagedAgent( throw new Error(created.spawnError); } - const attached = await attachManagedAgentToChannel(channelId, { + return { agent: created.agent, - role, - ensureRunning, + created: true, + runtimeId: input.runtime.id, + }; +} + +export async function createChannelManagedAgent( + channelId: string, + input: CreateChannelManagedAgentInput, + context?: { + managedAgents?: ManagedAgent[]; + channelMemberPubkeys?: ReadonlySet; + }, +): Promise { + const provisioned = await provisionChannelManagedAgent(input, context); + const attached = await attachManagedAgentToChannel(channelId, { + agent: provisioned.agent, + role: input.role ?? "bot", + ensureRunning: input.ensureRunning ?? true, }); return { ...attached, - created: true, - runtimeId: input.runtime.id, + created: provisioned.created, + runtimeId: provisioned.runtimeId, }; } diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 93edb30d12..9b30f2e8ad 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -5,9 +5,13 @@ import { attachManagedAgentToChannel, createChannelManagedAgents, ensureChannelAgentPresetInChannel, + provisionChannelManagedAgent, } from "@/features/agents/channelAgents"; -import { channelsQueryKey } from "@/features/channels/hooks"; import { resolveSnapshotAvatarPng } from "@/features/agents/ui/snapshotAvatarPng"; +import { + channelsQueryKey, + upsertCachedChannelMember, +} from "@/features/channels/hooks"; import { evictUsersBatchEntries } from "@/features/profile/hooks"; import { createManagedAgent, @@ -18,6 +22,7 @@ import { discoverManagedAgentPrereqs, getAgentConfigSurface, getBakedBuildEnvKeys, + getChannelMembers, getManagedAgentLog, getRuntimeFileConfig, installAcpRuntime, @@ -58,6 +63,7 @@ import type { AcpRuntime, AgentPersona, AgentTeam, + Channel, CreateManagedAgentInput, CreatePersonaInput, CreateTeamInput, @@ -66,6 +72,7 @@ import type { UpdatePersonaInput, UpdateTeamInput, } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import type { AttachManagedAgentToChannelInput, CreateChannelManagedAgentInput, @@ -73,6 +80,7 @@ import type { CreateChannelManagedAgentResult, EnsureChannelAgentPresetInput, EnsureChannelAgentPresetResult, + ProvisionChannelManagedAgentResult, } from "@/features/agents/channelAgents"; export { findReusableAgent } from "@/features/agents/agentReuse"; export type { @@ -84,6 +92,7 @@ export type { CreateChannelManagedAgentResult, EnsureChannelAgentPresetInput, EnsureChannelAgentPresetResult, + ProvisionChannelManagedAgentResult, } from "@/features/agents/channelAgents"; export const relayAgentsQueryKey = ["relay-agents"] as const; @@ -95,14 +104,22 @@ export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const; export const backendProvidersQueryKey = ["backend-providers"] as const; export const gitBashPrerequisiteQueryKey = ["git-bash-prerequisite"] as const; +type InvalidateAgentQueriesOptions = { + refetchChannels?: boolean; +}; + async function invalidateAgentQueries( queryClient: ReturnType, channelId: string | null, + options: InvalidateAgentQueriesOptions = {}, ) { await Promise.all([ queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }), queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }), - queryClient.invalidateQueries({ queryKey: channelsQueryKey }), + queryClient.invalidateQueries({ + queryKey: channelsQueryKey, + refetchType: options.refetchChannels === false ? "none" : "active", + }), ...(channelId ? [ queryClient.invalidateQueries({ @@ -122,9 +139,27 @@ function refreshAgentQueriesInBackground(task: () => Promise) { function invalidateAgentQueriesInBackground( queryClient: ReturnType, channelId: string | null, + options?: InvalidateAgentQueriesOptions, ) { refreshAgentQueriesInBackground(() => - invalidateAgentQueries(queryClient, channelId), + invalidateAgentQueries(queryClient, channelId, options), + ); +} + +function isCachedDmChannel( + queryClient: ReturnType, + channelId: string | null, +) { + if (!channelId) { + return false; + } + + return Boolean( + queryClient + .getQueryData(channelsQueryKey) + ?.some( + (channel) => channel.id === channelId && channel.channelType === "dm", + ), ); } @@ -524,13 +559,33 @@ export function useAttachManagedAgentToChannelMutation( return attachManagedAgentToChannel(effectiveChannelId, rest); }, + onSuccess: (result, variables) => { + const effectiveChannelId = variables.channelId ?? channelId; + if (!effectiveChannelId) { + return; + } + + queryClient.setQueryData(channelsQueryKey, (current) => + upsertCachedChannelMember(current, effectiveChannelId, { + membershipAdded: result.membershipAdded, + name: result.agent.name, + pubkey: result.agent.pubkey, + }), + ); + }, onSettled: (_data, _err, variables) => { // Invalidate the effective channel (the one the server actually mutated) // so its membership/agent state stays fresh. Invalidating the live // hook-closure channelId when the user has already switched away would // leave the compose-time channel stale. const effectiveChannelId = variables?.channelId ?? channelId; - invalidateAgentQueriesInBackground(queryClient, effectiveChannelId); + // Stream membership is already applied to channelsQueryKey by onSuccess, + // so avoid replacing it with a lagged snapshot. DM membership is + // immutable: adding the agent creates a separate conversation, so refresh + // the list to discover that target instead of decorating the source DM. + invalidateAgentQueriesInBackground(queryClient, effectiveChannelId, { + refetchChannels: isCachedDmChannel(queryClient, effectiveChannelId), + }); }, }); } @@ -578,9 +633,75 @@ export function useCreateChannelManagedAgentMutation(channelId: string | null) { const failure = result.failures[0]; throw new Error(failure?.error ?? "Could not create agent."); }, + onSuccess: (result, variables) => { + const effectiveChannelId = variables.channelId ?? channelId; + if (!effectiveChannelId) { + return; + } + + queryClient.setQueryData(channelsQueryKey, (current) => + upsertCachedChannelMember(current, effectiveChannelId, { + membershipAdded: result.membershipAdded, + name: result.agent.name, + pubkey: result.agent.pubkey, + }), + ); + }, onSettled: (_data, _err, variables) => { const effectiveChannelId = variables?.channelId ?? channelId; - invalidateAgentQueriesInBackground(queryClient, effectiveChannelId); + // Stream membership is already applied to channelsQueryKey by onSuccess, + // while immutable DM membership produces a separate conversation that + // must be discovered by refetching the channel list. + invalidateAgentQueriesInBackground(queryClient, effectiveChannelId, { + refetchChannels: isCachedDmChannel(queryClient, effectiveChannelId), + }); + }, + }); +} + +export function useProvisionChannelManagedAgentMutation( + channelId: string | null, +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ( + input: CreateChannelManagedAgentInput & { channelId?: string }, + ): Promise => { + const { channelId: capturedChannelId, ...rest } = input; + const effectiveChannelId = capturedChannelId ?? channelId; + if (!effectiveChannelId) { + throw new Error("No channel selected."); + } + + const [managedAgents, members] = await Promise.all([ + listManagedAgents(), + getChannelMembers(effectiveChannelId), + ]); + return provisionChannelManagedAgent(rest, { + managedAgents, + channelMemberPubkeys: new Set( + members.map((member) => normalizePubkey(member.pubkey)), + ), + }); + }, + onSuccess: (result) => { + queryClient.setQueryData( + managedAgentsQueryKey, + (current) => { + const next = current ?? []; + return [ + result.agent, + ...next.filter((agent) => agent.pubkey !== result.agent.pubkey), + ]; + }, + ); + }, + onSettled: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }), + queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }), + ]); }, }); } diff --git a/desktop/src/features/channels/hooks.test.mjs b/desktop/src/features/channels/hooks.test.mjs new file mode 100644 index 0000000000..7967fe8ce9 --- /dev/null +++ b/desktop/src/features/channels/hooks.test.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + reconcileRefreshedCachedChannel, + upsertCachedChannel, + upsertCachedChannelMember, +} from "./hooks.ts"; + +function makeChannel( + id, + name, + channelType = "stream", + { participantPubkeys = [], participants = [] } = {}, +) { + return { + id, + name, + channelType, + visibility: channelType === "dm" ? "private" : "open", + description: "", + topic: null, + purpose: null, + memberCount: participantPubkeys.length, + memberPubkeys: [...participantPubkeys], + lastMessageAt: null, + archivedAt: null, + participants, + participantPubkeys, + isMember: true, + ttlSeconds: null, + ttlDeadline: null, + }; +} + +test("upsertCachedChannel_reseedsOpenedDmAfterStaleRefetch", () => { + const staleChannels = [makeChannel("general", "General")]; + const openedDm = makeChannel("new-dm", "Alice", "dm"); + + const repairedChannels = upsertCachedChannel(staleChannels, openedDm); + + assert.strictEqual( + repairedChannels.find((channel) => channel.id === openedDm.id), + openedDm, + "the route must be able to resolve the exact relay-returned DM", + ); +}); + +test("upsertCachedChannel_replacesExistingChannelWithoutDuplicates", () => { + const staleDm = makeChannel("new-dm", "Old name", "dm"); + const openedDm = makeChannel("new-dm", "Alice", "dm"); + + const repairedChannels = upsertCachedChannel([staleDm], openedDm); + + assert.deepEqual(repairedChannels, [openedDm]); +}); + +test("upsertCachedChannelMember_doesNotDecorateImmutableDmSource", () => { + const charliePubkey = "charlie-pubkey"; + const ownerPubkey = "owner-pubkey"; + const fizzPubkey = "fizz-pubkey"; + const openedDm = makeChannel("new-dm", "DM", "dm", { + participantPubkeys: [charliePubkey, ownerPubkey], + participants: ["charlie", "owner"], + }); + + const channels = upsertCachedChannelMember([openedDm], openedDm.id, { + membershipAdded: true, + name: "Fizz", + pubkey: fizzPubkey, + }); + assert.deepEqual(channels, [openedDm]); +}); + +test("upsertCachedChannelMember_recordsStreamMemberBeforeRefetch", () => { + const fizzPubkey = "fizz-pubkey"; + const channel = makeChannel("general", "General"); + + const channels = upsertCachedChannelMember([channel], channel.id, { + membershipAdded: true, + name: "Fizz", + pubkey: fizzPubkey, + }); + + assert.deepEqual(channels?.[0].memberPubkeys, [fizzPubkey]); + assert.equal(channels?.[0].memberCount, 1); +}); + +test("reconcileRefreshedCachedChannel_restoresOpenedDmAfterStaleRefresh", () => { + const charliePubkey = "charlie-pubkey"; + const ownerPubkey = "owner-pubkey"; + const fizzPubkey = "fizz-pubkey"; + const openedDm = makeChannel("new-dm", "DM", "dm", { + participantPubkeys: [charliePubkey, ownerPubkey], + participants: ["charlie", "owner"], + }); + const expandedDm = makeChannel("expanded-dm", "Group DM", "dm", { + participantPubkeys: [charliePubkey, ownerPubkey, fizzPubkey], + participants: ["charlie", "owner", "Fizz"], + }); + + const reconciled = reconcileRefreshedCachedChannel([openedDm], expandedDm); + + assert.deepEqual(reconciled[1].participantPubkeys, [ + charliePubkey, + ownerPubkey, + fizzPubkey, + ]); + assert.deepEqual(reconciled[0], openedDm); +}); + +test("reconcileRefreshedCachedChannel_preservesRefreshedDmRecency", () => { + const charliePubkey = "charlie-pubkey"; + const ownerPubkey = "owner-pubkey"; + const openedDm = makeChannel("new-dm", "DM", "dm", { + participantPubkeys: [charliePubkey, ownerPubkey], + participants: ["charlie", "owner"], + }); + const refreshedDm = { + ...openedDm, + lastMessageAt: "2026-07-14T11:21:26Z", + name: "Group DM (3)", + }; + + const reconciled = reconcileRefreshedCachedChannel([refreshedDm], openedDm); + + assert.equal(reconciled[0].lastMessageAt, refreshedDm.lastMessageAt); + assert.equal(reconciled[0].name, refreshedDm.name); + assert.deepEqual(reconciled[0].participantPubkeys, [ + charliePubkey, + ownerPubkey, + ]); +}); diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 2c41ff1e74..58f432beac 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -67,6 +67,87 @@ function sortChannels(channels: Channel[]) { }); } +export type CachedChannelMember = { + membershipAdded: boolean; + name: string; + pubkey: string; +}; + +/** + * Records a successful membership mutation in the shared channel list before + * its read-after-write refetch completes. DM participant sets are immutable, + * so adding a member there creates a separate conversation and must never + * decorate the source channel optimistically. Exported for focused cache race + * regression coverage. + */ +export function upsertCachedChannelMember( + current: Channel[] | undefined, + channelId: string, + member: CachedChannelMember, +): Channel[] | undefined { + if (!current) { + return current; + } + + const normalizedPubkey = member.pubkey.toLowerCase(); + return sortChannels( + current.map((channel) => { + if (channel.id !== channelId) { + return channel; + } + + if (channel.channelType === "dm") { + return channel; + } + + const hasMember = channel.memberPubkeys.some( + (pubkey) => pubkey.toLowerCase() === normalizedPubkey, + ); + const memberPubkeys = hasMember + ? channel.memberPubkeys + : [...channel.memberPubkeys, member.pubkey]; + return { + ...channel, + memberCount: Math.max( + memberPubkeys.length, + channel.memberCount + (member.membershipAdded && !hasMember ? 1 : 0), + ), + memberPubkeys, + }; + }), + ); +} + +/** + * Adds or replaces a relay-returned channel in a possibly stale channel list. + * Exported for focused cache race regression coverage. + */ +export function upsertCachedChannel( + current: Channel[] | undefined, + channel: Channel, +): Channel[] { + return sortChannels([ + ...(current ?? []).filter((candidate) => candidate.id !== channel.id), + channel, + ]); +} + +/** + * Reconciles a relay-returned channel after a list refresh. When the refresh + * already contains the immutable DM, its current metadata wins over the older + * snapshot used to open the route. Otherwise the opened channel repairs the + * route after a read-after-write-lagged list response. + */ +export function reconcileRefreshedCachedChannel( + refreshed: Channel[] | undefined, + channel: Channel, +): Channel[] { + const refreshedChannel = refreshed?.find( + (candidate) => candidate.id === channel.id, + ); + return upsertCachedChannel(refreshed, refreshedChannel ?? channel); +} + async function invalidateChannelState( queryClient: ReturnType, channelId: string | null | undefined, @@ -142,11 +223,8 @@ export function useCreateChannelMutation() { return useMutation({ mutationFn: (input: CreateChannelInput) => createChannel(input), onSuccess: (createdChannel) => { - queryClient.setQueryData(channelsQueryKey, (current = []) => - sortChannels([ - ...current.filter((channel) => channel.id !== createdChannel.id), - createdChannel, - ]), + queryClient.setQueryData(channelsQueryKey, (current) => + upsertCachedChannel(current, createdChannel), ); }, onSettled: () => { @@ -167,11 +245,8 @@ export function useOpenDmMutation() { return useMutation({ mutationFn: (input: OpenDmInput) => openDm(input), onSuccess: (openedChannel) => { - queryClient.setQueryData(channelsQueryKey, (current = []) => - sortChannels([ - ...current.filter((channel) => channel.id !== openedChannel.id), - openedChannel, - ]), + queryClient.setQueryData(channelsQueryKey, (current) => + upsertCachedChannel(current, openedChannel), ); }, onSettled: () => { @@ -180,6 +255,28 @@ export function useOpenDmMutation() { }); } +/** + * Waits for any active channel-list refresh to settle, then restores a + * relay-returned channel to the shared cache before a caller depends on it for + * navigation. + */ +export function useUpsertCachedChannel() { + const queryClient = useQueryClient(); + + return React.useCallback( + async (channel: Channel) => { + await queryClient.refetchQueries({ + queryKey: channelsQueryKey, + type: "active", + }); + queryClient.setQueryData(channelsQueryKey, (current) => + reconcileRefreshedCachedChannel(current, channel), + ); + }, + [queryClient], + ); +} + export function useHideDmMutation() { const queryClient = useQueryClient(); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 0bbc4262f0..4a074f522f 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -51,6 +51,7 @@ import { } from "@/features/channels/ui/ChannelPane.helpers"; import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; +import { usePrepareDmSendChannel } from "@/features/channels/ui/usePrepareDmSendChannel"; import { Button } from "@/shared/ui/button"; import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; @@ -158,6 +159,10 @@ export const ChannelPane = React.memo(function ChannelPane({ const [welcomeComposerBannerState, setWelcomeComposerBannerState] = React.useState("prompt"); const { goChannel } = useAppNavigation(); + const prepareDmSendChannel = usePrepareDmSendChannel( + activeChannel, + currentPubkey, + ); const mainComposerMedia = useMediaUpload(); const isNonMemberView = activeChannel !== null && @@ -166,6 +171,15 @@ export const ChannelPane = React.memo(function ChannelPane({ !activeChannel.archivedAt; const hasMainComposerOverlay = !isNonMemberView; const activeChannelId = activeChannel?.id ?? null; + const activeChannelIdRef = React.useRef(activeChannelId); + const channelPaneMountedRef = React.useRef(false); + activeChannelIdRef.current = activeChannelId; + React.useEffect(() => { + channelPaneMountedRef.current = true; + return () => { + channelPaneMountedRef.current = false; + }; + }, []); // Clear the ?autoSend search param once the auto-submit fires so // back-navigation cannot re-trigger the send. // When `onAutoSendComplete` is provided it does a surgical single-key clear @@ -350,12 +364,23 @@ export const ChannelPane = React.memo(function ChannelPane({ messageTimelineRef.current?.scrollToBottomOnNextUpdate(); await onSendMessage(content, mentionPubkeys, mediaTags, channelId); + if ( + channelId && + channelId !== activeChannelId && + channelPaneMountedRef.current && + activeChannelIdRef.current === activeChannelId + ) { + await goChannel(channelId, { replace: true }); + } + if (shouldCompleteWelcomeBanner) { completeWelcomeComposerBanner(); } }, [ + activeChannelId, completeWelcomeComposerBanner, + goChannel, isActiveWelcomeChannel, knownAgentPubkeys, onSendMessage, @@ -721,6 +746,11 @@ export const ChannelPane = React.memo(function ChannelPane({ onCancelEdit={onCancelEdit} onEditLastOwnMessage={handleEditLastOwnMainMessage} onEditSave={onEditSave} + onPrepareSendChannel={ + activeChannel?.channelType === "dm" + ? prepareDmSendChannel + : undefined + } onSend={handleSendMessage} profiles={profiles} placeholder={ diff --git a/desktop/src/features/channels/ui/usePrepareDmSendChannel.ts b/desktop/src/features/channels/ui/usePrepareDmSendChannel.ts new file mode 100644 index 0000000000..c9f3488ee8 --- /dev/null +++ b/desktop/src/features/channels/ui/usePrepareDmSendChannel.ts @@ -0,0 +1,55 @@ +import * as React from "react"; + +import { + useOpenDmMutation, + useUpsertCachedChannel, +} from "@/features/channels/hooks"; +import type { Channel } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export function usePrepareDmSendChannel( + activeChannel: Channel | null, + currentPubkey?: string, +) { + const openDmMutation = useOpenDmMutation(); + const upsertCachedChannel = useUpsertCachedChannel(); + + return React.useCallback( + async (additionalParticipantPubkeys: string[] = []) => { + if (activeChannel?.channelType !== "dm") { + return activeChannel?.id ?? null; + } + + const currentParticipantPubkeys = new Set( + activeChannel.participantPubkeys.map(normalizePubkey), + ); + const requiresExpandedDm = additionalParticipantPubkeys.some( + (pubkey) => !currentParticipantPubkeys.has(normalizePubkey(pubkey)), + ); + if (!requiresExpandedDm) { + return activeChannel.id; + } + + const currentNormalizedPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + const pubkeys = [ + ...new Set( + [ + ...activeChannel.participantPubkeys, + ...additionalParticipantPubkeys, + ].map(normalizePubkey), + ), + ].filter((pubkey) => pubkey && pubkey !== currentNormalizedPubkey); + const expandedDm = await openDmMutation.mutateAsync({ pubkeys }); + await upsertCachedChannel(expandedDm); + return expandedDm.id; + }, + [ + activeChannel, + currentPubkey, + openDmMutation.mutateAsync, + upsertCachedChannel, + ], + ); +} diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 4ab0c62ff7..2c2ef7e051 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -151,6 +151,25 @@ export function resolveEffectiveChannel( return channelsCache?.find((c) => c.id === capturedChannelId) ?? null; } +/** + * Resolves a send target captured as either the channel object itself or its id. + * A relay-returned channel remains authoritative even when the shared channel + * list is temporarily stale and does not contain it. + * + * Exported for unit testing. + */ +export function resolveSendChannel( + targetChannel: Channel | undefined, + capturedChannelId: string | null | undefined, + channelsCache: Channel[] | undefined, + fallbackChannel: Channel | null, +): Channel | null { + return ( + targetChannel ?? + resolveEffectiveChannel(capturedChannelId, channelsCache, fallbackChannel) + ); +} + /** * Resolves the thread reply target from a submit-time captured context or, * for callers that predate the capture pattern, from live refs. @@ -377,6 +396,7 @@ export function useSendMessageMutation( Error, { channelId?: string; + targetChannel?: Channel; content: string; mentionPubkeys?: string[]; parentEventId?: string | null; @@ -386,27 +406,31 @@ export function useSendMessageMutation( >({ mutationFn: async ({ channelId: capturedChannelId, + targetChannel, content, mentionPubkeys, parentEventId, mediaTags, }) => { - // Resolve the target channel from the compose-time id when provided, so - // a channel switch mid-send does not redirect the message. Fall back to - // the closed-over `channel` for callers that don't supply a capturedId. - // A supplied-but-unresolvable id throws rather than silently falling back - // to the live channel (silent misdelivery is the failure mode we're fixing). - const effectiveChannel = resolveEffectiveChannel( + // Prefer a channel captured by the caller at compose time. Otherwise, + // resolve a captured id from the shared channel cache so navigation + // cannot redirect the message. Legacy callers without either value use + // the closed-over `channel`. + const effectiveChannel = resolveSendChannel( + targetChannel, capturedChannelId, queryClient.getQueryData(channelsQueryKey), channel, ); - if (capturedChannelId != null && effectiveChannel == null) { - throw new Error("Channel is no longer available."); + if (effectiveChannel == null) { + if (capturedChannelId != null) { + throw new Error("Channel is no longer available."); + } + throw new Error("This channel does not support message sending yet."); } - if (!effectiveChannel || effectiveChannel.channelType === "forum") { + if (effectiveChannel.channelType === "forum") { throw new Error("This channel does not support message sending yet."); } @@ -494,16 +518,17 @@ export function useSendMessageMutation( }, onMutate: async ({ channelId: capturedChannelId, + targetChannel, content, mentionPubkeys, parentEventId, mediaTags, }) => { - // Mirror the mutationFn channel resolution so the optimistic message - // lands in the same cache key the real send will eventually populate. - // A supplied-but-unresolvable id returns undefined (skips optimistic write) - // rather than silently writing to the live channel. - const effectiveChannel = resolveEffectiveChannel( + // Mirror mutationFn's target resolution so the optimistic message lands + // in the cache for the same channel as the real send. A caller-supplied + // channel remains valid even when a stale channel-list read omitted it. + const effectiveChannel = resolveSendChannel( + targetChannel, capturedChannelId, queryClient.getQueryData(channelsQueryKey), channel, diff --git a/desktop/src/features/messages/lib/sendChannelBinding.test.mjs b/desktop/src/features/messages/lib/sendChannelBinding.test.mjs index a038e8827d..421799d373 100644 --- a/desktop/src/features/messages/lib/sendChannelBinding.test.mjs +++ b/desktop/src/features/messages/lib/sendChannelBinding.test.mjs @@ -15,6 +15,8 @@ * so the caller can throw rather than silently misdeliver. * 4. resolveEffectiveChannel falls back to the closed-over channel when no * capturedChannelId was supplied (legacy-caller path). + * 5. resolveSendChannel uses a relay-returned channel object even when a + * stale shared channel list does not contain the captured id. */ import assert from "node:assert/strict"; @@ -23,6 +25,7 @@ import test from "node:test"; import { createOptimisticMessage, resolveEffectiveChannel, + resolveSendChannel, resolveThreadReplyTarget, } from "../hooks.ts"; @@ -203,6 +206,37 @@ test("resolveEffectiveChannel_emptyCache_capturedIdPresent_returnsNull", () => { assert.strictEqual(result, null); }); +// --------------------------------------------------------------------------- +// resolveSendChannel — direct channel capture for read-after-write safety +// --------------------------------------------------------------------------- + +test("resolveSendChannel_staleCacheMissingOpenedDm_returnsCapturedDm", () => { + const openedDm = { ...makeChannel("new-dm"), channelType: "dm" }; + const unrelatedChannel = makeChannel("channel-B"); + + const result = resolveSendChannel( + openedDm, + openedDm.id, + [unrelatedChannel], + unrelatedChannel, + ); + + assert.strictEqual(result, openedDm); +}); + +test("resolveSendChannel_withoutCapturedObject_preservesIdSafety", () => { + const unrelatedChannel = makeChannel("channel-B"); + + const result = resolveSendChannel( + undefined, + "missing-channel", + [unrelatedChannel], + unrelatedChannel, + ); + + assert.strictEqual(result, null); +}); + // --------------------------------------------------------------------------- // resolveThreadReplyTarget — flush-time resolution for handleSendThreadReply // diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 39512fa0ae..b56b0fa9c3 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -103,16 +103,14 @@ type MessageComposerProps = { */ onEditLastOwnMessage?: () => boolean; onEditSave?: (content: string, mediaTags?: string[][]) => Promise; - /** - * Called synchronously at the start of `submitMessage`, before any awaits, - * to capture context that must be stable throughout the async send pipeline. - * Used by the thread-reply composer to capture the current reply target before - * the mention-flow awaits can change navigation state. - */ + /** Captures send context synchronously before awaits can change navigation. */ onCaptureSendContext?: () => { parentEventId: string | null; threadHeadId: string | null; } | null; + /** Resolves the channel required to prepare mentions before sending. */ + onPrepareSendChannel?: (pubkeys?: string[]) => Promise; + onPreparingMentionSendChange?: (isPreparing: boolean) => void; onSend: ( content: string, mentionPubkeys: string[], @@ -152,6 +150,8 @@ function MessageComposerImpl({ onCaptureSendContext, onEditLastOwnMessage, onEditSave, + onPrepareSendChannel, + onPreparingMentionSendChange, onSend, placeholder, profiles, @@ -345,6 +345,7 @@ function MessageComposerImpl({ drafts, emojiAutocomplete, mentions, + onPrepareSendChannel, onSendRef, richText, setContent: setComposerContent, @@ -608,8 +609,6 @@ function MessageComposerImpl({ } const capturedThreadContext = onCaptureSendContext?.() ?? null; - // If a thread-reply composer reported no reply target at submit time, - // bail here rather than discovering the null later after async awaits. if ( capturedThreadContext !== null && !capturedThreadContext.parentEventId @@ -617,22 +616,22 @@ function MessageComposerImpl({ return; } - await mentionSendFlow.sendMessageWithMentionFlow({ - capturedChannelId: channelId, - capturedThreadContext, - pendingImeta: currentPendingImeta, - // resolveSentDraftKey checks at submit time (synchronously, before any - // await) whether a draft was actually persisted. If not — fast/ - // never-persisted send — it returns null so the active draft is not - // cleared (nothing to clear). The function is exported and tested directly - // in MessageComposerDraftPredicate.test.mjs. - sentDraftKey: resolveSentDraftKey( - effectiveDraftKeyRef.current, - drafts.loadDraft, - ), - spoileredAttachmentUrls, - trimmed, - }); + onPreparingMentionSendChange?.(true); + try { + await mentionSendFlow.sendMessageWithMentionFlow({ + capturedChannelId: channelId, + capturedThreadContext, + pendingImeta: currentPendingImeta, + sentDraftKey: resolveSentDraftKey( + effectiveDraftKeyRef.current, + drafts.loadDraft, + ), + spoileredAttachmentUrls, + trimmed, + }); + } finally { + onPreparingMentionSendChange?.(false); + } }, [ channelId, channelLinks.clearChannels, @@ -650,6 +649,7 @@ function MessageComposerImpl({ spoileredAttachmentUrls, syncComposerContentFromEditor, onCaptureSendContext, + onPreparingMentionSendChange, ]); submitMessageRef.current = submitMessage; diff --git a/desktop/src/features/messages/ui/NewMessageResultRow.tsx b/desktop/src/features/messages/ui/NewMessageResultRow.tsx new file mode 100644 index 0000000000..8fab14122b --- /dev/null +++ b/desktop/src/features/messages/ui/NewMessageResultRow.tsx @@ -0,0 +1,149 @@ +import { Bot } from "lucide-react"; + +import { formatOwnerLabel } from "@/features/profile/lib/identity"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { UserSearchResult } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { truncatePubkey } from "@/shared/lib/pubkey"; + +import { formatRecipientName } from "./useNewMessageRecipients"; + +const RESULT_ROW_INSET_DIVIDER_CLASS = + "after:pointer-events-none after:absolute after:bottom-0 after:left-[3.75rem] after:right-0 after:h-px after:bg-border/60 after:content-[''] last:after:hidden"; +const TEXT_SWAP_BASE_CLASS = + "min-w-0 truncate transition-[opacity,filter] duration-[250ms] ease-in-out motion-reduce:transition-none"; +const TEXT_SWAP_VISIBLE_CLASS = "opacity-100 blur-0"; +const TEXT_SWAP_HIDDEN_CLASS = "opacity-0 blur-0"; +const TEXT_SWAP_HOVER_VISIBLE_CLASS = + "group-hover/name:opacity-100 group-hover/name:blur-0 group-focus-visible/dm-result:opacity-100 group-focus-visible/dm-result:blur-0"; +const TEXT_SWAP_HOVER_HIDDEN_CLASS = + "group-hover/name:opacity-0 group-hover/name:blur-[2px] group-focus-visible/dm-result:opacity-0 group-focus-visible/dm-result:blur-[2px]"; + +function HoverRecipientIdentity({ + displayName, + pubkey, +}: { + displayName: string; + pubkey: string; +}) { + const identityLabel = truncatePubkey(pubkey); + + return ( + + + {displayName} + + + {identityLabel} + + + ); +} + +/** + * A single selectable person/agent row in the new-message directory. Extracted + * from the former NewDirectMessageDialog so the compose page renders identical + * rows (avatar, agent badge, owner label, and a name-to-pubkey hover swap). + */ +export function NewMessageResultRow({ + currentPubkey, + disabled, + isAlreadySelected = false, + isKeyboardHighlighted = false, + onSelect, + ownerProfiles, + user, +}: { + currentPubkey?: string; + disabled: boolean; + isAlreadySelected?: boolean; + isKeyboardHighlighted?: boolean; + onSelect: (user: UserSearchResult) => void; + ownerProfiles?: UserProfileLookup; + user: UserSearchResult; +}) { + const name = formatRecipientName(user); + const ownerLabel = formatOwnerLabel( + user.ownerPubkey, + currentPubkey, + ownerProfiles, + ); + + return ( +
+ +
+ ); +} diff --git a/desktop/src/features/messages/ui/NewMessageScreen.tsx b/desktop/src/features/messages/ui/NewMessageScreen.tsx new file mode 100644 index 0000000000..30b4d25db8 --- /dev/null +++ b/desktop/src/features/messages/ui/NewMessageScreen.tsx @@ -0,0 +1,687 @@ +import { Bot, X } from "lucide-react"; +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { + useOpenDmMutation, + useUpsertCachedChannel, +} from "@/features/channels/hooks"; +import type { Channel } from "@/shared/api/types"; +import { useSendMessageMutation } from "@/features/messages/hooks"; +import { getKeyboardSearchSelection } from "@/features/profile/lib/userCandidateSearch"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + POOF_ORIGIN_CLASS, + POOF_TRIGGER_CLASS, +} from "@/shared/ui/PoofBurstProvider"; +import { PubKey } from "@/shared/ui/PubKey"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { Skeleton } from "@/shared/ui/skeleton"; + +import { MessageComposer } from "./MessageComposer"; +import { NewMessageResultRow } from "./NewMessageResultRow"; +import { + formatRecipientName, + useNewMessageRecipients, +} from "./useNewMessageRecipients"; + +/** + * Conversation-shaped compose surface for starting a direct message. The + * normal chat header becomes an inline "To:" field, while recipient discovery + * lives in an attached popover instead of taking over the message area. + */ +export function NewMessageScreen() { + const identityQuery = useIdentityQuery(); + const currentPubkey = identityQuery.data?.pubkey; + const openDmMutation = useOpenDmMutation(); + const upsertCachedChannel = useUpsertCachedChannel(); + const sendMessageMutation = useSendMessageMutation(null, identityQuery.data); + const { goChannel } = useAppNavigation(); + + const [isRecipientPickerOpen, setIsRecipientPickerOpen] = + React.useState(true); + const [highlightedRecipientPubkey, setHighlightedRecipientPubkey] = + React.useState(null); + const [inspectedRecipientPubkey, setInspectedRecipientPubkey] = + React.useState(null); + const [submitErrorMessage, setSubmitErrorMessage] = React.useState< + string | null + >(null); + const [isPreparingMentionSend, setIsPreparingMentionSend] = + React.useState(false); + const searchInputRef = React.useRef(null); + const toFieldRef = React.useRef(null); + const preparedDirectMessageRef = React.useRef(null); + const isMountedRef = React.useRef(false); + const isPending = + isPreparingMentionSend || + openDmMutation.isPending || + sendMessageMutation.isPending; + + const { + deferredSearchQuery, + handleDirectoryScroll, + hasReachedRecipientLimit, + isDirectoryLoading, + ownerProfiles, + removeUser, + searchError, + searchQuery, + searchResults, + selectUser, + selectedUsers, + setSearchQuery, + } = useNewMessageRecipients({ active: true, currentPubkey }); + + const isSearchTransitionPending = searchQuery.trim() !== deferredSearchQuery; + const visibleSearchResults = + isSearchTransitionPending || isDirectoryLoading ? [] : searchResults; + const showRecipientPicker = isRecipientPickerOpen && !isPending; + const highlightedRecipientIndex = React.useMemo(() => { + if (!showRecipientPicker || visibleSearchResults.length === 0) { + return -1; + } + + if (highlightedRecipientPubkey === null) { + return 0; + } + + return visibleSearchResults.findIndex( + (user) => user.pubkey === highlightedRecipientPubkey, + ); + }, [highlightedRecipientPubkey, showRecipientPicker, visibleSearchResults]); + const highlightedRecipient = + highlightedRecipientIndex < 0 + ? null + : (visibleSearchResults[highlightedRecipientIndex] ?? null); + + React.useEffect(() => { + if ( + highlightedRecipientPubkey && + !isSearchTransitionPending && + highlightedRecipientIndex < 0 + ) { + setHighlightedRecipientPubkey(null); + } + }, [ + highlightedRecipientIndex, + highlightedRecipientPubkey, + isSearchTransitionPending, + ]); + + React.useEffect(() => { + if (!highlightedRecipient || !showRecipientPicker) { + return; + } + + document + .getElementById(`new-dm-option-${highlightedRecipient.pubkey}`) + ?.scrollIntoView({ block: "nearest" }); + }, [highlightedRecipient, showRecipientPicker]); + + React.useEffect(() => { + isMountedRef.current = true; + searchInputRef.current?.focus({ preventScroll: true }); + + return () => { + isMountedRef.current = false; + }; + }, []); + + const handleRemoveUser = React.useCallback( + (pubkey: string) => { + preparedDirectMessageRef.current = null; + setInspectedRecipientPubkey((current) => + current === pubkey ? null : current, + ); + removeUser(pubkey); + }, + [removeUser], + ); + + const handleSelectUser = React.useCallback( + (user: Parameters[0]) => { + preparedDirectMessageRef.current = null; + selectUser(user); + setHighlightedRecipientPubkey(null); + setSubmitErrorMessage(null); + setIsRecipientPickerOpen(true); + searchInputRef.current?.focus({ preventScroll: true }); + }, + [selectUser], + ); + + const handleResultSelect = React.useCallback( + (user: Parameters[0]) => { + const isSelected = selectedUsers.some( + (selectedUser) => selectedUser.pubkey === user.pubkey, + ); + if (isSelected) { + setSearchQuery(""); + setHighlightedRecipientPubkey(null); + setSubmitErrorMessage(null); + setIsRecipientPickerOpen(true); + searchInputRef.current?.focus({ preventScroll: true }); + return; + } + + handleSelectUser(user); + }, + [handleSelectUser, selectedUsers, setSearchQuery], + ); + + const openDirectMessage = React.useCallback( + async (additionalParticipantPubkeys: string[] = []) => { + const requestedPubkeys = [ + ...new Set( + [ + ...selectedUsers.map((user) => user.pubkey), + ...additionalParticipantPubkeys, + ].map(normalizePubkey), + ), + ].filter(Boolean); + const preparedDirectMessage = preparedDirectMessageRef.current; + const currentNormalizedPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + const preparedParticipantPubkeys = new Set( + (preparedDirectMessage?.participantPubkeys ?? []) + .map(normalizePubkey) + .filter((pubkey) => pubkey !== currentNormalizedPubkey), + ); + if ( + preparedDirectMessage && + preparedParticipantPubkeys.size === requestedPubkeys.length && + requestedPubkeys.every((pubkey) => + preparedParticipantPubkeys.has(pubkey), + ) + ) { + return preparedDirectMessage; + } + + if ( + openDmMutation.isPending || + sendMessageMutation.isPending || + requestedPubkeys.length === 0 + ) { + return null; + } + + setSubmitErrorMessage(null); + + try { + const directMessage = await openDmMutation.mutateAsync({ + pubkeys: requestedPubkeys, + }); + preparedDirectMessageRef.current = directMessage; + return directMessage; + } catch (error) { + setSubmitErrorMessage( + error instanceof Error + ? error.message + : "Failed to open direct message.", + ); + return null; + } + }, + [ + currentPubkey, + openDmMutation.isPending, + openDmMutation.mutateAsync, + selectedUsers, + sendMessageMutation.isPending, + ], + ); + + const prepareSendChannel = React.useCallback( + async (additionalParticipantPubkeys: string[] = []) => { + const directMessage = await openDirectMessage( + additionalParticipantPubkeys, + ); + return directMessage?.id ?? null; + }, + [openDirectMessage], + ); + + const sendFirstMessage = React.useCallback( + async ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + targetChannelId?: string | null, + ) => { + const preparedDirectMessage = preparedDirectMessageRef.current; + const directMessage = + targetChannelId && preparedDirectMessage?.id === targetChannelId + ? preparedDirectMessage + : await openDirectMessage(); + if (!directMessage) { + throw new Error( + submitErrorMessage ?? "Choose at least one recipient first.", + ); + } + + if (!isMountedRef.current) { + return; + } + + try { + await sendMessageMutation.mutateAsync({ + targetChannel: directMessage, + content, + mentionPubkeys, + mediaTags, + }); + } catch (error) { + preparedDirectMessageRef.current = null; + const message = + error instanceof Error ? error.message : "Failed to send message."; + setSubmitErrorMessage(message); + throw error; + } + + if (!isMountedRef.current) { + return; + } + + await upsertCachedChannel(directMessage); + if (!isMountedRef.current) { + return; + } + await goChannel(directMessage.id, { replace: true }); + }, + [ + goChannel, + openDirectMessage, + sendMessageMutation, + submitErrorMessage, + upsertCachedChannel, + ], + ); + + const composerPlaceholder = + selectedUsers.length === 0 + ? "Choose a recipient to start a message" + : selectedUsers.length === 1 + ? `Message ${formatRecipientName(selectedUsers[0])}` + : `Message ${selectedUsers.length} people`; + + return ( +
+
+
+ { + setIsRecipientPickerOpen( + open || inspectedRecipientPubkey !== null, + ); + }} + open={showRecipientPicker} + > + + {/* biome-ignore lint/a11y/noStaticElementInteractions: clicking anywhere in the recipient field focuses its input */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: the nested combobox is the keyboard-accessible focus target */} +
{ + setIsRecipientPickerOpen(true); + searchInputRef.current?.focus({ preventScroll: true }); + }} + ref={toFieldRef} + > + + To: + + {selectedUsers.map((user) => ( +
+ + { + setInspectedRecipientPubkey(open ? user.pubkey : null); + }} + open={inspectedRecipientPubkey === user.pubkey} + > + + + + event.preventDefault()} + > +

+ Verify {formatRecipientName(user)} +

+ +

+ {user.pubkey} +

+
+
+ {user.isAgent ? ( + + ) : null} +
+ ))} + { + setSearchQuery(event.target.value); + setHighlightedRecipientPubkey(null); + setSubmitErrorMessage(null); + setIsRecipientPickerOpen(true); + }} + onFocus={() => setIsRecipientPickerOpen(true)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + if (inspectedRecipientPubkey) { + setInspectedRecipientPubkey(null); + return; + } + setHighlightedRecipientPubkey(null); + setIsRecipientPickerOpen(false); + return; + } + + if ( + (event.key === "ArrowDown" || event.key === "ArrowUp") && + visibleSearchResults.length > 0 + ) { + event.preventDefault(); + setIsRecipientPickerOpen(true); + setHighlightedRecipientPubkey(() => { + const current = highlightedRecipientIndex; + if (current < 0) { + const initialIndex = + event.key === "ArrowDown" + ? 0 + : visibleSearchResults.length - 1; + return ( + visibleSearchResults[initialIndex]?.pubkey ?? null + ); + } + + const direction = event.key === "ArrowDown" ? 1 : -1; + const nextIndex = + (current + direction + visibleSearchResults.length) % + visibleSearchResults.length; + return visibleSearchResults[nextIndex]?.pubkey ?? null; + }); + return; + } + + if ( + event.key === "Backspace" && + searchQuery.length === 0 && + selectedUsers.length > 0 + ) { + event.preventDefault(); + const lastRecipient = + selectedUsers[selectedUsers.length - 1]; + if (lastRecipient) { + document + .querySelector( + `[data-testid="new-dm-selected-${lastRecipient.pubkey}"]`, + ) + ?.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + detail: 0, + }), + ); + } + return; + } + + if (event.key !== "Enter") { + return; + } + + if (searchQuery.trim().length === 0) { + if (highlightedRecipient) { + event.preventDefault(); + handleSelectUser(highlightedRecipient); + } + return; + } + + const keyboardSelection = + highlightedRecipient ?? + getKeyboardSearchSelection({ + currentQuery: searchQuery, + rankedQuery: deferredSearchQuery, + results: visibleSearchResults, + }); + if (!keyboardSelection) { + return; + } + + event.preventDefault(); + handleResultSelect(keyboardSelection); + }} + ref={searchInputRef} + role="combobox" + spellCheck={false} + type="text" + value={searchQuery} + /> +
+
+ event.preventDefault()} + onInteractOutside={(event) => { + const target = event.detail.originalEvent.target; + if ( + target instanceof Element && + (toFieldRef.current?.contains(target) || + Boolean(target.closest("[data-new-dm-key-popover]"))) + ) { + event.preventDefault(); + } + }} + onOpenAutoFocus={(event) => event.preventDefault()} + side="bottom" + sideOffset={6} + > +
+ {visibleSearchResults.length > 0 ? ( +
+ {visibleSearchResults.map((user) => { + const isSelected = selectedUsers.some( + (selectedUser) => selectedUser.pubkey === user.pubkey, + ); + return ( + + ); + })} +
+ ) : isDirectoryLoading || isSearchTransitionPending ? ( +
+ {["w-40", "w-32", "w-48"].map((nameWidth) => ( +
+ +
+ + +
+
+ ))} +
+ ) : ( +

+ {deferredSearchQuery.length === 0 + ? "No people or agents available to message." + : "No matching users."} +

+ )} +
+
+
+ + {isPending ? ( + + Opening… + + ) : null} +
+
+ +
+ + {hasReachedRecipientLimit ? ( +

+ DMs support up to nine people, including you. +

+ ) : null} + {searchError ? ( +

+ {searchError.message} +

+ ) : null} + {submitErrorMessage ? ( +

+ {submitErrorMessage} +

+ ) : null} + + + + ); +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index c0082ce156..c76c0cb52f 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -2,10 +2,12 @@ import * as React from "react"; import { toast } from "sonner"; import { + type CreateChannelManagedAgentInput, useAttachManagedAgentToChannelMutation, useAvailableAcpRuntimes, useCreateChannelManagedAgentMutation, useManagedAgentsQuery, + useProvisionChannelManagedAgentMutation, useStartManagedAgentMutation, } from "@/features/agents/hooks"; import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; @@ -37,6 +39,7 @@ type PendingNonMemberMentionSend = { mentionPubkeys: string[]; nonMemberPubkeys: string[]; outgoingTags?: string[][]; + preparedManagedAgents?: ManagedAgent[]; readyAgentPubkeys?: string[]; savedContent: string; savedImeta: ImetaMedia[]; @@ -66,6 +69,9 @@ type UseMentionSendFlowOptions = { drafts: Pick; emojiAutocomplete: Pick; mentions: UseMentionsResult; + onPrepareSendChannel?: ( + additionalParticipantPubkeys?: string[], + ) => Promise; onSendRef: React.MutableRefObject< ( content: string, @@ -118,6 +124,11 @@ function isProviderBackedAgent(agent: ManagedAgent) { return agent.backend.type === "provider"; } +const DM_THREAD_AGENT_MENTION_ERROR = + "Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent."; +const DM_THREAD_MEMBERS_LOADING_ERROR = + "Checking conversation members. Try again in a moment."; + export function useMentionSendFlow({ channelId, channelLinks, @@ -127,6 +138,7 @@ export function useMentionSendFlow({ drafts, emojiAutocomplete, mentions, + onPrepareSendChannel, onSendRef, richText, setContent, @@ -144,16 +156,25 @@ export function useMentionSendFlow({ React.useState(false); const isMentionSendPendingRef = React.useRef(false); const isCompleteSendPendingRef = React.useRef(false); + const isMountedRef = React.useRef(false); const previousChannelIdRef = React.useRef(channelId); // Tracks the live channel so completeSend can ask "is the user still here?" // without being frozen to the compose-time closure. const channelIdRef = React.useRef(channelId); channelIdRef.current = channelId; + React.useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); const addMembersMutation = useAddChannelMembersMutation(channelId); const attachAgentMutation = useAttachManagedAgentToChannelMutation(channelId); const createPersonaAgentMutation = useCreateChannelManagedAgentMutation(channelId); + const provisionPersonaAgentMutation = + useProvisionChannelManagedAgentMutation(channelId); const availableRuntimesQuery = useAvailableAcpRuntimes(); const managedAgentsQuery = useManagedAgentsQuery(); const startAgentMutation = useStartManagedAgentMutation(); @@ -191,13 +212,29 @@ export function useMentionSendFlow({ ]); const ensureManagedAgentMentionsReady = React.useCallback( - async (mentionPubkeys: string[], capturedChannelId: string) => { + async ( + mentionPubkeys: string[], + capturedChannelId: string, + preparedParticipantPubkeys: string[] = [], + preparedManagedAgents: ManagedAgent[] = [], + ) => { if (!capturedChannelId || mentionPubkeys.length === 0) { - return []; + return { + errors: [] as string[], + pubkeys: [] as string[], + }; } const managedAgentsByPubkey = await getManagedAgentsByPubkey(); + for (const agent of preparedManagedAgents) { + managedAgentsByPubkey.set(normalizePubkey(agent.pubkey), agent); + } + const participantPubkeys = new Set([ + ...mentions.memberPubkeys, + ...preparedParticipantPubkeys.map(normalizePubkey), + ]); const errors: string[] = []; + const pubkeys: string[] = []; for (const pubkey of uniqueNormalizedPubkeys(mentionPubkeys)) { const agent = managedAgentsByPubkey.get(pubkey); @@ -206,7 +243,7 @@ export function useMentionSendFlow({ } try { - if (mentions.memberPubkeys.has(pubkey)) { + if (participantPubkeys.has(pubkey)) { if (isProviderBackedAgent(agent)) { if (agent.status !== "deployed") { await startAgentMutation.mutateAsync(agent.pubkey); @@ -221,6 +258,7 @@ export function useMentionSendFlow({ role: "bot", }); } + pubkeys.push(pubkey); } catch (error) { errors.push( `${agent.name}: ${getErrorMessage( @@ -231,7 +269,10 @@ export function useMentionSendFlow({ } } - return errors; + return { + errors, + pubkeys: uniqueNormalizedPubkeys(pubkeys), + }; }, [ attachAgentMutation, @@ -247,6 +288,7 @@ export function useMentionSendFlow({ if (!capturedChannelId || personaMentions.length === 0) { return { errors: [] as string[], + agents: [] as ManagedAgent[], pubkeys: [] as string[], }; } @@ -254,8 +296,11 @@ export function useMentionSendFlow({ const runtimes = await getAvailableRuntimes(); const defaultRuntime = runtimes[0] ?? null; const errors: string[] = []; + const agents: ManagedAgent[] = []; const pubkeys: string[] = []; const seenPersonaIds = new Set(); + const shouldProvisionForDm = + channelType === "dm" && Boolean(onPrepareSendChannel); for (const { displayName, persona } of personaMentions) { if (seenPersonaIds.has(persona.id)) { @@ -274,7 +319,9 @@ export function useMentionSendFlow({ } try { - const result = await createPersonaAgentMutation.mutateAsync({ + const input: CreateChannelManagedAgentInput & { + channelId: string; + } = { channelId: capturedChannelId, runtime, name: persona.displayName, @@ -284,8 +331,12 @@ export function useMentionSendFlow({ model: persona.model ?? undefined, role: "bot", ensureRunning: true, - }); + }; + const result = shouldProvisionForDm + ? await provisionPersonaAgentMutation.mutateAsync(input) + : await createPersonaAgentMutation.mutateAsync(input); const pubkey = normalizePubkey(result.agent.pubkey); + agents.push(result.agent); pubkeys.push(pubkey); mentions.registerMentionPubkey(displayName, pubkey, { isAgent: true, @@ -301,15 +352,19 @@ export function useMentionSendFlow({ } return { + agents, errors, pubkeys: uniqueNormalizedPubkeys(pubkeys), }; }, [ createPersonaAgentMutation, + channelType, getAvailableRuntimes, mentions.extractMentionPersonas, mentions.registerMentionPubkey, + onPrepareSendChannel, + provisionPersonaAgentMutation, ], ); @@ -363,17 +418,53 @@ export function useMentionSendFlow({ const readyAgentPubkeys = new Set( (draft.readyAgentPubkeys ?? []).map(normalizePubkey), ); - const agentReadinessErrors = await ensureManagedAgentMentionsReady( - mentionPubkeys.filter( + const managedAgentsByPubkey = await getManagedAgentsByPubkey(); + if (!isMountedRef.current) { + return; + } + for (const agent of draft.preparedManagedAgents ?? []) { + managedAgentsByPubkey.set(normalizePubkey(agent.pubkey), agent); + } + const normalizedMentionPubkeys = + uniqueNormalizedPubkeys(mentionPubkeys); + const managedMentionPubkeys = normalizedMentionPubkeys.filter( + (pubkey) => managedAgentsByPubkey.has(pubkey), + ); + const agentMentionPubkeys = uniqueNormalizedPubkeys([ + ...managedMentionPubkeys, + ...normalizedMentionPubkeys.filter(mentions.isAgentPubkey), + ]); + const preparedAgentPubkeys = uniqueNormalizedPubkeys([ + ...readyAgentPubkeys, + ...agentMentionPubkeys, + ]); + let sendChannelId = draft.capturedChannelId; + if (preparedAgentPubkeys.length > 0 && onPrepareSendChannel) { + sendChannelId = await onPrepareSendChannel(preparedAgentPubkeys); + if (!sendChannelId) { + return; + } + if (!isMountedRef.current) { + return; + } + } + + const agentReadiness = await ensureManagedAgentMentionsReady( + managedMentionPubkeys.filter( (pubkey) => !readyAgentPubkeys.has(normalizePubkey(pubkey)), ), - draft.capturedChannelId ?? "", + sendChannelId ?? "", + onPrepareSendChannel ? preparedAgentPubkeys : [], + [...managedAgentsByPubkey.values()], ); - if (agentReadinessErrors.length > 0) { + if (!isMountedRef.current) { + return; + } + if (agentReadiness.errors.length > 0) { const message = - agentReadinessErrors.length === 1 - ? `Could not start agent mention: ${agentReadinessErrors[0]}` - : `Could not start agent mentions: ${agentReadinessErrors.join( + agentReadiness.errors.length === 1 + ? `Could not start agent mention: ${agentReadiness.errors[0]}` + : `Could not start agent mentions: ${agentReadiness.errors.join( "; ", )}`; setNonMemberPromptError(message); @@ -393,14 +484,14 @@ export function useMentionSendFlow({ draft.finalContent, mentionPubkeys, outgoingTags, - draft.capturedChannelId, + sendChannelId, draft.capturedThreadContext, ); if (draft.sentDraftKey) { drafts.markDraftSent( draft.sentDraftKey, draft.savedContent, - draft.capturedChannelId ?? draft.sentDraftKey, + sendChannelId ?? draft.sentDraftKey, draft.savedImeta, [...draft.savedSpoileredAttachmentUrls], ); @@ -420,7 +511,9 @@ export function useMentionSendFlow({ } } finally { isCompleteSendPendingRef.current = false; - setIsCompleteSendPending(false); + if (isMountedRef.current) { + setIsCompleteSendPending(false); + } } }, [ @@ -428,6 +521,9 @@ export function useMentionSendFlow({ contentRef, drafts, ensureManagedAgentMentionsReady, + getManagedAgentsByPubkey, + mentions.isAgentPubkey, + onPrepareSendChannel, onSendRef, richText.setContent, setContent, @@ -453,6 +549,46 @@ export function useMentionSendFlow({ [channelType, mentions.hasResolvedMembers, mentions.memberPubkeys], ); + const getDmThreadAgentMentionError = React.useCallback( + ( + trimmed: string, + capturedThreadContext: SendMessageWithMentionFlowInput["capturedThreadContext"], + ) => { + if (channelType !== "dm" || capturedThreadContext == null) { + return null; + } + + if (mentions.extractMentionPersonas(trimmed).length > 0) { + return DM_THREAD_AGENT_MENTION_ERROR; + } + + const agentPubkeys = mentions + .extractMentionPubkeys(trimmed) + .filter(mentions.isAgentPubkey); + if (agentPubkeys.length === 0) { + return null; + } + + if (!mentions.hasResolvedMembers) { + return DM_THREAD_MEMBERS_LOADING_ERROR; + } + + return agentPubkeys.some( + (pubkey) => !mentions.memberPubkeys.has(normalizePubkey(pubkey)), + ) + ? DM_THREAD_AGENT_MENTION_ERROR + : null; + }, + [ + channelType, + mentions.extractMentionPersonas, + mentions.extractMentionPubkeys, + mentions.hasResolvedMembers, + mentions.isAgentPubkey, + mentions.memberPubkeys, + ], + ); + const sendMessageWithMentionFlow = React.useCallback( async ({ capturedChannelId, @@ -469,9 +605,27 @@ export function useMentionSendFlow({ isMentionSendPendingRef.current = true; setIsMentionSendPending(true); try { + const dmThreadAgentMentionError = getDmThreadAgentMentionError( + trimmed, + capturedThreadContext, + ); + if (dmThreadAgentMentionError) { + setNonMemberPromptError(dmThreadAgentMentionError); + toast.error(dmThreadAgentMentionError); + return; + } + + let effectiveChannelId = capturedChannelId; + if (!effectiveChannelId && onPrepareSendChannel) { + effectiveChannelId = await onPrepareSendChannel(); + if (!effectiveChannelId) { + return; + } + } + const personaMentionResult = await createMentionedPersonaAgents( trimmed, - capturedChannelId ?? "", + effectiveChannelId ?? "", ); if (personaMentionResult.errors.length > 0) { const message = @@ -522,13 +676,17 @@ export function useMentionSendFlow({ } const pendingDraft: PendingNonMemberMentionSend = { - capturedChannelId, + capturedChannelId: effectiveChannelId, capturedThreadContext, finalContent, mentionPubkeys: pubkeys, nonMemberPubkeys: promptNonMemberPubkeys, outgoingTags, - readyAgentPubkeys: createdPersonaAgentPubkeys, + preparedManagedAgents: personaMentionResult.agents, + readyAgentPubkeys: + channelType === "dm" && onPrepareSendChannel + ? [] + : createdPersonaAgentPubkeys, savedContent: trimmed, savedImeta: [...pendingImeta], savedSpoileredAttachmentUrls: new Set(spoileredAttachmentUrls), @@ -549,12 +707,15 @@ export function useMentionSendFlow({ }, [ completeSend, + channelType, createMentionedPersonaAgents, customEmoji, getManagedAgentsByPubkey, getNonMemberMentionPubkeys, + getDmThreadAgentMentionError, mentions.extractMentionPubkeys, mentions.isManagedAgentPubkey, + onPrepareSendChannel, ], ); diff --git a/desktop/src/features/messages/ui/useNewMessageRecipients.ts b/desktop/src/features/messages/ui/useNewMessageRecipients.ts new file mode 100644 index 0000000000..c9ea05e428 --- /dev/null +++ b/desktop/src/features/messages/ui/useNewMessageRecipients.ts @@ -0,0 +1,329 @@ +import * as React from "react"; + +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; +import { + coalesceAgentAutocompleteCandidates, + getMentionableAgentPubkeys, + getSharedChannelIds, +} from "@/features/agents/lib/agentAutocompleteEligibility"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; +import { + useFlattenedUserSearchResults, + useInfiniteUserSearchQuery, + useUserSearchFetchMoreOnScroll, + useUsersBatchQuery, +} from "@/features/profile/hooks"; +import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { ManagedAgent, UserSearchResult } from "@/shared/api/types"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; + +/** Maximum recipients (excluding the current user) a DM can address. */ +export const NEW_MESSAGE_RECIPIENT_LIMIT = 8; + +const DIRECTORY_PAGE_SIZE = 50; + +export type NewMessageRecipientCandidate = UserSearchResult & { + isManagedAgent?: boolean; + isMember?: boolean; + personaId?: string | null; +}; + +export function formatRecipientName(user: UserSearchResult) { + return ( + user.displayName?.trim() || + user.nip05Handle?.trim() || + truncatePubkey(user.pubkey) + ); +} + +function candidateWithAgentMetadata( + candidate: UserSearchResult, + managedAgentsByPubkey: ReadonlyMap, +): NewMessageRecipientCandidate { + const agent = managedAgentsByPubkey.get(normalizePubkey(candidate.pubkey)); + return { + ...candidate, + isManagedAgent: Boolean(agent), + personaId: agent?.personaId, + }; +} + +/** + * Shared recipient-picker state for the new-message compose surface: search + * query, selected recipients (chips), the ranked candidate directory, and the + * paging/owner-profile helpers the row UI needs. Extracted from the former + * NewDirectMessageDialog so the compose page (and any future surface) share a + * single, tested selection model. + */ +export function useNewMessageRecipients({ + active, + currentPubkey, +}: { + /** When false, the directory/agent queries stay idle. */ + active: boolean; + currentPubkey?: string; +}) { + const [searchQuery, setSearchQuery] = React.useState(""); + const [directoryIdentityQuery, setDirectoryIdentityQuery] = React.useState< + string | null + >(null); + const [selectedUsers, setSelectedUsers] = React.useState( + [], + ); + const selectedUsersCountRef = React.useRef(0); + const deferredSearchQuery = React.useDeferredValue(searchQuery.trim()); + const hasReachedRecipientLimit = + selectedUsers.length >= NEW_MESSAGE_RECIPIENT_LIMIT; + + const selectedPubkeys = React.useMemo( + () => new Set(selectedUsers.map((user) => normalizePubkey(user.pubkey))), + [selectedUsers], + ); + + const identityQuery = useIdentityQuery(); + const managedAgentsQuery = useManagedAgentsQuery({ enabled: active }); + const relayAgentsQuery = useRelayAgentsQuery({ enabled: active }); + const channelsQuery = useChannelsQuery({ enabled: active }); + const userSearchQuery = useInfiniteUserSearchQuery(deferredSearchQuery, { + allowEmpty: true, + enabled: + active && (!hasReachedRecipientLimit || deferredSearchQuery.length > 0), + limit: DIRECTORY_PAGE_SIZE, + }); + const userSearchResults = useFlattenedUserSearchResults(userSearchQuery.data); + const isArchivedDiscovery = useIsArchivedPredicate(); + + const searchResults = React.useMemo(() => { + const candidatesByPubkey = new Map(); + const managedAgentsByPubkey = new Map( + (managedAgentsQuery.data ?? []).map((agent) => [ + normalizePubkey(agent.pubkey), + agent, + ]), + ); + const currentPubkeyNormalized = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + const eligibleAgentPubkeys = getMentionableAgentPubkeys({ + currentPubkey, + managedAgentPubkeys: (managedAgentsQuery.data ?? []).map( + (agent) => agent.pubkey, + ), + relayAgents: relayAgentsQuery.data, + sharedChannelIds: getSharedChannelIds(channelsQuery.data), + }); + + const addCandidate = ( + candidate: NewMessageRecipientCandidate, + options: { includeSelected?: boolean } = {}, + ) => { + const pubkey = normalizePubkey(candidate.pubkey); + + if ( + pubkey === currentPubkeyNormalized || + (!options.includeSelected && selectedPubkeys.has(pubkey)) || + isArchivedDiscovery(pubkey) || + (candidate.isAgent && !eligibleAgentPubkeys.has(pubkey)) + ) { + return; + } + + const current = candidatesByPubkey.get(pubkey); + if (!current) { + candidatesByPubkey.set(pubkey, { ...candidate, pubkey }); + return; + } + + const candidateName = candidate.displayName?.trim() || null; + const currentName = current.displayName?.trim() || null; + + candidatesByPubkey.set(pubkey, { + pubkey, + avatarUrl: current.avatarUrl ?? candidate.avatarUrl ?? null, + displayName: + candidate.isAgent && candidateName + ? candidateName + : current.isAgent + ? currentName + : (currentName ?? candidateName), + nip05Handle: current.nip05Handle ?? candidate.nip05Handle ?? null, + ownerPubkey: current.ownerPubkey ?? candidate.ownerPubkey ?? null, + isAgent: current.isAgent || candidate.isAgent, + isManagedAgent: current.isManagedAgent || candidate.isManagedAgent, + isMember: current.isMember || candidate.isMember, + personaId: current.personaId ?? candidate.personaId, + }); + }; + + for (const user of userSearchResults) { + addCandidate(candidateWithAgentMetadata(user, managedAgentsByPubkey), { + includeSelected: deferredSearchQuery.length > 0, + }); + } + + for (const agent of relayAgentsQuery.data ?? []) { + if (!eligibleAgentPubkeys.has(normalizePubkey(agent.pubkey))) { + continue; + } + + addCandidate( + { + pubkey: agent.pubkey, + displayName: agent.name, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + isAgent: true, + }, + { includeSelected: deferredSearchQuery.length > 0 }, + ); + } + + for (const agent of managedAgentsQuery.data ?? []) { + addCandidate( + { + pubkey: agent.pubkey, + displayName: agent.name, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: currentPubkey ?? null, + isAgent: true, + isManagedAgent: true, + personaId: agent.personaId, + }, + { includeSelected: deferredSearchQuery.length > 0 }, + ); + } + + const coalescedCandidates = coalesceAgentAutocompleteCandidates( + [...candidatesByPubkey.values()], + { + currentPubkey, + getLabel: formatRecipientName, + }, + ); + + return rankUserCandidatesBySearch({ + allowEmptyQuery: true, + candidates: coalescedCandidates, + getLabel: formatRecipientName, + limit: Math.max(DIRECTORY_PAGE_SIZE, coalescedCandidates.length), + query: deferredSearchQuery, + }); + }, [ + channelsQuery.data, + currentPubkey, + deferredSearchQuery, + isArchivedDiscovery, + managedAgentsQuery.data, + relayAgentsQuery.data, + selectedPubkeys, + userSearchResults, + ]); + + const isDirectoryLoading = + userSearchQuery.isLoading || + managedAgentsQuery.isLoading || + relayAgentsQuery.isLoading || + channelsQuery.isLoading; + React.useEffect(() => { + if (isDirectoryLoading) { + return; + } + + setDirectoryIdentityQuery(deferredSearchQuery); + }, [deferredSearchQuery, isDirectoryLoading]); + const isDirectorySettling = + isDirectoryLoading || directoryIdentityQuery !== deferredSearchQuery; + const handleDirectoryScroll = useUserSearchFetchMoreOnScroll( + userSearchQuery, + !hasReachedRecipientLimit || deferredSearchQuery.length > 0, + ); + + const searchOwnerPubkeys = React.useMemo( + () => [ + ...new Set( + searchResults + .map((user) => user.ownerPubkey) + .filter((pubkey): pubkey is string => + Boolean( + pubkey && + pubkey.toLowerCase() !== + identityQuery.data?.pubkey?.toLowerCase(), + ), + ), + ), + ], + [identityQuery.data?.pubkey, searchResults], + ); + const ownerProfilesQuery = useUsersBatchQuery(searchOwnerPubkeys, { + enabled: active && searchOwnerPubkeys.length > 0, + }); + + // Clearing the query on each new chip mirrors the modal's behavior: the + // search box empties so the next recipient can be typed immediately. + React.useEffect(() => { + if (selectedUsers.length > selectedUsersCountRef.current) { + setSearchQuery(""); + } + selectedUsersCountRef.current = selectedUsers.length; + }, [selectedUsers.length]); + + const selectUser = React.useCallback( + (user: UserSearchResult) => { + if (selectedUsers.length >= NEW_MESSAGE_RECIPIENT_LIMIT) { + return; + } + + setSelectedUsers((current) => { + const pubkey = normalizePubkey(user.pubkey); + if ( + current.some( + (candidate) => normalizePubkey(candidate.pubkey) === pubkey, + ) + ) { + return current; + } + + return [...current, user]; + }); + setSearchQuery(""); + }, + [selectedUsers.length], + ); + + const removeUser = React.useCallback((pubkey: string) => { + setSelectedUsers((current) => + current.filter((candidate) => candidate.pubkey !== pubkey), + ); + }, []); + + const reset = React.useCallback(() => { + setSearchQuery(""); + setSelectedUsers([]); + selectedUsersCountRef.current = 0; + }, []); + + return { + currentPubkey: currentPubkey ?? identityQuery.data?.pubkey, + deferredSearchQuery, + handleDirectoryScroll, + hasReachedRecipientLimit, + isDirectoryLoading: isDirectorySettling, + ownerProfiles: ownerProfilesQuery.data?.profiles, + removeUser, + reset, + searchError: + userSearchQuery.error instanceof Error ? userSearchQuery.error : null, + searchQuery, + searchResults, + selectUser, + selectedUsers, + setSearchQuery, + }; +} diff --git a/desktop/src/features/projects/projectPullRequests.d.mts b/desktop/src/features/projects/projectPullRequests.d.mts index b9a7652b12..a426b34ef4 100644 --- a/desktop/src/features/projects/projectPullRequests.d.mts +++ b/desktop/src/features/projects/projectPullRequests.d.mts @@ -43,6 +43,7 @@ export type ProjectPullRequest = { approvals: ProjectPullRequestApproval[]; status: "Open" | "Merged" | "Closed" | "Draft"; statusEventId: string | null; + statusCreatedAt: number | null; branchName: string | null; initialCommit: string | null; commit: string | null; @@ -59,6 +60,10 @@ export function eventToProjectPullRequest( commentEvents?: RelayEvent[], statusEvents?: RelayEvent[], ): ProjectPullRequest; +export function nextProjectPullRequestStatusCreatedAt( + pullRequest: Pick, + now: number, +): number; export function projectPullRequestEventsToPullRequests( pullRequestEvents: RelayEvent[], updateEvents?: RelayEvent[], diff --git a/desktop/src/features/projects/projectPullRequests.mjs b/desktop/src/features/projects/projectPullRequests.mjs index 24721bea09..67beccccb3 100644 --- a/desktop/src/features/projects/projectPullRequests.mjs +++ b/desktop/src/features/projects/projectPullRequests.mjs @@ -61,6 +61,12 @@ function statusFromEvent(pullRequest, statusEvent) { return labels.includes("draft") ? "Draft" : "Open"; } +/** Keep consecutive lifecycle writes ordered even when they happen within the + * same whole-second Nostr timestamp. */ +export function nextProjectPullRequestStatusCreatedAt(pullRequest, now) { + return Math.max(now, (pullRequest.statusCreatedAt ?? 0) + 1); +} + function eventToPullRequestUpdate(event) { return { id: event.id, @@ -172,6 +178,7 @@ export function eventToProjectPullRequest( approvals, status: statusFromEvent(pullRequest, latestStatus), statusEventId: latestStatus?.id ?? null, + statusCreatedAt: latestStatus?.created_at ?? null, branchName: getTag(pullRequest, "branch-name") ?? null, initialCommit, commit: latestCommit, diff --git a/desktop/src/features/projects/projectPullRequests.test.mjs b/desktop/src/features/projects/projectPullRequests.test.mjs index 553a7a1ddd..73b347b53b 100644 --- a/desktop/src/features/projects/projectPullRequests.test.mjs +++ b/desktop/src/features/projects/projectPullRequests.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { eventToProjectPullRequest } from "./projectPullRequests.mjs"; +import { + eventToProjectPullRequest, + nextProjectPullRequestStatusCreatedAt, +} from "./projectPullRequests.mjs"; const OWNER = "a".repeat(64); const AUTHOR = "b".repeat(64); @@ -186,6 +189,15 @@ test("draft/ready toggles via status kinds 1633 and 1630", () => { [draft], ); assert.equal(draftPullRequest.status, "Draft"); + assert.equal(draftPullRequest.statusCreatedAt, 300); + assert.equal( + nextProjectPullRequestStatusCreatedAt(draftPullRequest, 300), + 301, + ); + assert.equal( + nextProjectPullRequestStatusCreatedAt(draftPullRequest, 400), + 400, + ); const reopened = statusEvent({ kind: 1630, pubkey: AUTHOR, createdAt: 400 }); const openPullRequest = eventToProjectPullRequest( diff --git a/desktop/src/features/projects/pullRequestReviews.ts b/desktop/src/features/projects/pullRequestReviews.ts index 0d7f7c7e9f..e4eec54116 100644 --- a/desktop/src/features/projects/pullRequestReviews.ts +++ b/desktop/src/features/projects/pullRequestReviews.ts @@ -10,7 +10,10 @@ import { KIND_TEXT_NOTE, } from "@/shared/constants/kinds"; import type { Project } from "./hooks"; -import type { ProjectPullRequest } from "./projectPullRequests.mjs"; +import { + nextProjectPullRequestStatusCreatedAt, + type ProjectPullRequest, +} from "./projectPullRequests.mjs"; import { PR_APPROVAL_LABEL, PR_REVIEW_REQUEST_LABEL, @@ -48,6 +51,10 @@ async function updateProjectPullRequestStatus({ const event = await signRelayEvent({ kind: PR_STATUS_KIND_BY_LIFECYCLE[status], content: "", + createdAt: nextProjectPullRequestStatusCreatedAt( + pullRequest, + Math.floor(Date.now() / 1_000), + ), tags: [ ["e", pullRequest.id, "", "root"], ["a", project.repoAddress], diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 47b8389e94..02081b639c 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -38,7 +38,6 @@ import { SectionQuickAction, } from "@/features/sidebar/ui/CustomChannelSection"; import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; -import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog"; import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard"; import { SidebarRelayConnectionCard } from "@/features/sidebar/ui/SidebarRelayConnectionCard"; import type { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; @@ -86,7 +85,6 @@ type AppSidebarProps = { isLoading: boolean; isCreatingChannel: boolean; isCreatingForum: boolean; - isOpeningDm: boolean; profile?: Profile; relayConnectionCard: ReturnType; selfPresenceStatus: PresenceStatus; @@ -95,6 +93,7 @@ type AppSidebarProps = { selectedView: | "home" | "channel" + | "messages" | "agents" | "workflows" | "pulse" @@ -155,8 +154,7 @@ type AppSidebarProps = { onSwitchWorkspace: (id: string) => void; selfUserStatus?: UserStatus; isPresencePending?: boolean; - isNewDmOpen?: boolean; - onNewDmOpenChange?: (open: boolean) => void; + onNewMessage: () => void; isCreateChannelOpen?: boolean; onCreateChannelOpenChange?: (open: boolean) => void; mutedChannelIds?: ReadonlySet; @@ -177,7 +175,6 @@ export function AppSidebar({ isLoading, isCreatingChannel, isCreatingForum, - isOpeningDm, profile, relayConnectionCard, selfPresenceStatus, @@ -217,8 +214,7 @@ export function AppSidebar({ onSwitchWorkspace, selfUserStatus, isPresencePending, - isNewDmOpen: isNewDmOpenProp, - onNewDmOpenChange, + onNewMessage, isCreateChannelOpen: isCreateChannelOpenProp, onCreateChannelOpenChange, mutedChannelIds, @@ -237,9 +233,6 @@ export function AppSidebar({ React.useState(false); const showSidebarUpdateCard = canShowSidebarUpdateCard && !isSidebarUpdateCardDismissed; - const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false); - const isNewDmOpen = isNewDmOpenProp ?? isNewDmOpenInternal; - const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal; const [dmActionsMenuOpen, setDmActionsMenuOpen] = React.useState(false); const scrollRef = React.useRef(null); useSidebarScrollLock(scrollRef); @@ -755,14 +748,14 @@ export function AppSidebar({
setIsNewDmOpen(true)} + onClick={onNewMessage} testId="section-actions-dms-quick-create" /> setIsNewDmOpen(true)} + onNewMessage={onNewMessage} sortMode={sortModeFor("dms")} onSortModeChange={(mode) => setSortModeFor("dms", mode)} /> @@ -876,14 +869,6 @@ export function AppSidebar({ onCreate={handleCreateFromDialog} /> - - {})} onSubmit={onAddWorkspace} diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index c36b18aaf7..79249dfa90 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -14,6 +14,7 @@ import { type SidebarSelectedView = | "home" | "channel" + | "messages" | "agents" | "workflows" | "pulse" diff --git a/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx b/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx deleted file mode 100644 index 9ab7a99421..0000000000 --- a/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx +++ /dev/null @@ -1,877 +0,0 @@ -import { Bot, Search, X } from "lucide-react"; -import * as React from "react"; - -import { - useManagedAgentsQuery, - useRelayAgentsQuery, -} from "@/features/agents/hooks"; -import { - coalesceAgentAutocompleteCandidates, - getMentionableAgentPubkeys, - getSharedChannelIds, -} from "@/features/agents/lib/agentAutocompleteEligibility"; -import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; -import { - useFlattenedUserSearchResults, - useInfiniteUserSearchQuery, - useUserSearchFetchMoreOnScroll, - useUsersBatchQuery, -} from "@/features/profile/hooks"; -import { formatOwnerLabel } from "@/features/profile/lib/identity"; -import { - getKeyboardSearchSelection, - rankUserCandidatesBySearch, -} from "@/features/profile/lib/userCandidateSearch"; -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; -import { useChannelsQuery } from "@/features/channels/hooks"; -import { useIdentityQuery } from "@/shared/api/hooks"; -import type { ManagedAgent, UserSearchResult } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; -import { safeNpub } from "@/shared/lib/nostrUtils"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; -import { PubKey } from "@/shared/ui/PubKey"; -import { Button } from "@/shared/ui/button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; -import { - MODAL_SEARCH_INPUT_CLASS, - MODAL_SEARCH_SHELL_CLASS, -} from "@/shared/ui/modalSearchStyles"; - -const DIRECT_MESSAGE_RECIPIENT_LIMIT = 50; -const DM_RESULT_ROW_INSET_DIVIDER_CLASS = - "after:pointer-events-none after:absolute after:bottom-0 after:left-[3.75rem] after:right-0 after:h-px after:bg-border/60 after:content-[''] last:after:hidden"; -const BUTTON_LABEL_MORPH_DURATION_MS = 220; -const BUTTON_LABEL_MORPH_EASE = "cubic-bezier(0.23, 1, 0.32, 1)"; -const BUTTON_LABEL_FADE_MS = Math.min( - BUTTON_LABEL_MORPH_DURATION_MS * 0.5, - 150, -); -const BUTTON_LABEL_EXIT_ATTR = "data-button-label-exiting"; -const BUTTON_LABEL_CURRENT_ATTR = "data-button-label-current"; - -function formatUserName(user: UserSearchResult) { - return ( - user.displayName?.trim() || - user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) - ); -} - -type DirectMessageSearchCandidate = UserSearchResult & { - isManagedAgent?: boolean; - isMember?: boolean; - personaId?: string | null; -}; - -function directMessageCandidateWithAgentMetadata( - candidate: UserSearchResult, - managedAgentsByPubkey: ReadonlyMap, -): DirectMessageSearchCandidate { - const agent = managedAgentsByPubkey.get(normalizePubkey(candidate.pubkey)); - return { - ...candidate, - isManagedAgent: Boolean(agent), - personaId: agent?.personaId, - }; -} - -function formatDirectMessageSearchName(user: DirectMessageSearchCandidate) { - return formatUserName(user); -} - -function prefersReducedMotion() { - return ( - typeof window !== "undefined" && - window.matchMedia("(prefers-reduced-motion: reduce)").matches - ); -} - -function createButtonLabelSpan(text: string) { - const span = document.createElement("span"); - span.setAttribute(BUTTON_LABEL_CURRENT_ATTR, ""); - span.textContent = text; - span.style.display = "inline-block"; - span.style.willChange = "opacity, transform"; - return span; -} - -function clearButtonLabelRoot(root: HTMLElement, text: string) { - root.replaceChildren(createButtonLabelSpan(text)); - root.style.width = "auto"; - root.style.height = "auto"; -} - -function MorphingButtonLabel({ text }: { text: string }) { - const rootRef = React.useRef(null); - const currentTextRef = React.useRef(""); - const cleanupSizeTransitionRef = React.useRef<(() => void) | null>(null); - - React.useLayoutEffect(() => { - const root = rootRef.current; - if (!root || text === currentTextRef.current) { - return; - } - const morphRoot = root; - - cleanupSizeTransitionRef.current?.(); - cleanupSizeTransitionRef.current = null; - - if (!currentTextRef.current || prefersReducedMotion()) { - clearButtonLabelRoot(root, text); - currentTextRef.current = text; - return; - } - - root.querySelectorAll(`[${BUTTON_LABEL_EXIT_ATTR}]`).forEach((element) => { - element.remove(); - }); - - const oldRect = root.getBoundingClientRect(); - const oldWidth = oldRect.width; - const oldHeight = oldRect.height; - const rootRect = root.getBoundingClientRect(); - const currentChild = root.querySelector( - `[${BUTTON_LABEL_CURRENT_ATTR}]`, - ); - - if (!currentChild || oldWidth === 0 || oldHeight === 0) { - clearButtonLabelRoot(root, text); - currentTextRef.current = text; - return; - } - - const currentChildRect = currentChild.getBoundingClientRect(); - const currentChildOpacity = - Number(getComputedStyle(currentChild).opacity) || 1; - currentChild.getAnimations().forEach((animation) => { - animation.cancel(); - }); - currentChild.removeAttribute(BUTTON_LABEL_CURRENT_ATTR); - currentChild.setAttribute(BUTTON_LABEL_EXIT_ATTR, ""); - currentChild.style.position = "absolute"; - currentChild.style.pointerEvents = "none"; - currentChild.style.left = `${currentChildRect.left - rootRect.left}px`; - currentChild.style.top = `${currentChildRect.top - rootRect.top}px`; - currentChild.style.width = `${currentChildRect.width}px`; - currentChild.style.height = `${currentChildRect.height}px`; - currentChild.style.opacity = String(currentChildOpacity); - - const nextChild = createButtonLabelSpan(text); - root.appendChild(nextChild); - - root.style.width = "auto"; - root.style.height = "auto"; - void root.offsetWidth; - - const nextRect = root.getBoundingClientRect(); - - root.style.width = `${oldWidth}px`; - root.style.height = `${oldHeight}px`; - void root.offsetWidth; - - root.style.width = `${nextRect.width}px`; - root.style.height = `${nextRect.height}px`; - - function cleanupSizeTransition() { - morphRoot.removeEventListener("transitionend", handleTransitionEnd); - window.clearTimeout(fallbackTimer); - cleanupSizeTransitionRef.current = null; - if (currentTextRef.current === text) { - morphRoot.style.width = "auto"; - morphRoot.style.height = "auto"; - } - } - - function handleTransitionEnd(event: TransitionEvent) { - if (event.target !== morphRoot) { - return; - } - if (event.propertyName !== "width" && event.propertyName !== "height") { - return; - } - cleanupSizeTransition(); - } - - root.addEventListener("transitionend", handleTransitionEnd); - const fallbackTimer = window.setTimeout( - cleanupSizeTransition, - BUTTON_LABEL_MORPH_DURATION_MS + 50, - ); - cleanupSizeTransitionRef.current = () => { - root.removeEventListener("transitionend", handleTransitionEnd); - window.clearTimeout(fallbackTimer); - }; - - currentChild.animate( - [{ transform: "none" }, { transform: "scale(0.95)" }], - { - duration: BUTTON_LABEL_MORPH_DURATION_MS, - easing: BUTTON_LABEL_MORPH_EASE, - fill: "both", - }, - ); - const exitFade = currentChild.animate( - [{ opacity: currentChildOpacity }, { opacity: 0 }], - { - duration: Math.min(BUTTON_LABEL_MORPH_DURATION_MS * 0.25, 150), - easing: "linear", - fill: "both", - }, - ); - exitFade.onfinish = () => currentChild.remove(); - - nextChild.animate([{ transform: "scale(0.95)" }, { transform: "none" }], { - duration: BUTTON_LABEL_MORPH_DURATION_MS, - easing: BUTTON_LABEL_MORPH_EASE, - fill: "both", - }); - nextChild.animate([{ opacity: 0 }, { opacity: 1 }], { - delay: Math.min(BUTTON_LABEL_MORPH_DURATION_MS * 0.25, 150), - duration: BUTTON_LABEL_FADE_MS, - easing: "linear", - fill: "both", - }); - - currentTextRef.current = text; - }, [text]); - - React.useEffect(() => { - return () => { - cleanupSizeTransitionRef.current?.(); - rootRef.current?.getAnimations({ subtree: true }).forEach((animation) => { - animation.cancel(); - }); - }; - }, []); - - return ( - <> -