From d1e27889ebe1d57370724d0d058a73b08762f9cf Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 27 May 2026 15:03:29 +0100 Subject: [PATCH] fix(site): improve chat sharing mobile layout (#25687) --- .../UserOrGroupAutocomplete.tsx | 4 +- .../AgentsPage/AgentChatPage.stories.tsx | 2 +- .../AgentsPage/AgentChatPageView.stories.tsx | 4 +- .../components/ChatSharingPopover.stories.tsx | 52 ++++- .../components/ChatSharingPopover.tsx | 208 +++++++++++++----- 5 files changed, 197 insertions(+), 73 deletions(-) diff --git a/site/src/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete.tsx b/site/src/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete.tsx index ff7e5dba59..4a09998718 100644 --- a/site/src/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete.tsx +++ b/site/src/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete.tsx @@ -22,6 +22,7 @@ type UserOrGroupAutocompleteProps = { onChange: (value: UserOrGroupAutocompleteValue) => void; organizationId: string; exclude: ExcludableOption[]; + className?: string; }; const normalizeMember = ( @@ -36,6 +37,7 @@ export const UserOrGroupAutocomplete: FC = ({ onChange, organizationId, exclude, + className = "w-80", }) => { const [inputValue, setInputValue] = useState(""); const [open, setOpen] = useState(false); @@ -132,7 +134,7 @@ export const UserOrGroupAutocomplete: FC = ({ loading={membersQuery.isFetching || groupsQuery.isFetching} placeholder="Search for user or group" noOptionsText="No users or groups found" - className="w-80" + className={className} id="workspace-user-or-group-autocomplete" /> ); diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index aeac0227b1..6fd809f9fd 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -1220,7 +1220,7 @@ export const RootChatShareActionAvailable: Story = { await userEvent.click(canvas.getByLabelText("Share chat")); const body = within(document.body); await waitFor(() => { - expect(body.getByText("Chat Sharing")).toBeVisible(); + expect(body.getByText("Chat sharing")).toBeVisible(); }); await waitFor(() => { expect(body.getByText("No shared members or groups yet")).toBeVisible(); diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index b14563d320..441984b059 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -1511,7 +1511,7 @@ export const ArchivedWithSharing: Story = { await userEvent.click(canvas.getByLabelText("Share chat")); const body = within(document.body); await waitFor(() => { - expect(body.getByText("Chat Sharing")).toBeVisible(); + expect(body.getByText("Chat sharing")).toBeVisible(); }); await waitFor(() => { expect(body.getByText("No shared members or groups yet")).toBeVisible(); @@ -1543,7 +1543,7 @@ export const ShareChatPopoverFromTopBar: Story = { await userEvent.click(canvas.getByLabelText("Share chat")); const body = within(document.body); await waitFor(() => { - expect(body.getByText("Chat Sharing")).toBeVisible(); + expect(body.getByText("Chat sharing")).toBeVisible(); }); await waitFor(() => { expect(body.getByText("No shared members or groups yet")).toBeVisible(); diff --git a/site/src/pages/AgentsPage/components/ChatSharingPopover.stories.tsx b/site/src/pages/AgentsPage/components/ChatSharingPopover.stories.tsx index df4b142b1d..b35f0b3347 100644 --- a/site/src/pages/AgentsPage/components/ChatSharingPopover.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSharingPopover.stories.tsx @@ -119,7 +119,7 @@ const openChatSharing = async (canvasElement: HTMLElement) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button", { name: "Share" })); const body = within(canvasElement.ownerDocument.body); - await body.findByText("Chat Sharing"); + await body.findByText("Chat sharing"); return body; }; @@ -128,7 +128,7 @@ const closeChatSharing = async (canvasElement: HTMLElement) => { const body = within(canvasElement.ownerDocument.body); await userEvent.click(canvas.getByRole("button", { name: "Share" })); await waitFor(() => { - expect(body.queryByText("Chat Sharing")).not.toBeInTheDocument(); + expect(body.queryByText("Chat sharing")).not.toBeInTheDocument(); }); }; @@ -153,6 +153,12 @@ const addAutocompleteOption = async ( await userEvent.click(body.getByRole("button", { name: "Add member" })); }; +const MobileFrame = (Story: React.FC) => ( +
+ +
+); + const meta: Meta = { title: "pages/AgentsPage/ChatSharingPopover", component: ChatShareButton, @@ -174,10 +180,13 @@ export const EmptyACL: Story = { play: async ({ canvasElement }) => { const body = await openChatSharing(canvasElement); await waitFor(() => { - expect(body.getByText("No shared members or groups yet")).toBeVisible(); expect( - body.getByText("Add a member or group using the controls above."), - ).toBeVisible(); + body.getAllByText("No shared members or groups yet").length, + ).toBeGreaterThan(0); + expect( + body.getAllByText("Add a member or group using the controls above.") + .length, + ).toBeGreaterThan(0); }); }, }; @@ -187,11 +196,32 @@ export const PopulatedACL: Story = { play: async ({ canvasElement }) => { const body = await openChatSharing(canvasElement); await waitFor(() => { - expect(body.getByText(chatUser.username)).toBeInTheDocument(); - expect(body.getByText(chatGroup.name)).toBeInTheDocument(); + expect(body.getAllByText(chatUser.username).length).toBeGreaterThan(0); + expect(body.getAllByText(chatGroup.name).length).toBeGreaterThan(0); expect(body.getAllByText("Read").length).toBeGreaterThan(0); }); - expect(body.getAllByRole("button", { name: "Open menu" })).toHaveLength(2); + expect( + body.getAllByRole("button", { name: "Open menu" }).length, + ).toBeGreaterThan(0); + }, +}; + +export const MobilePopulatedACL: Story = { + decorators: [MobileFrame], + parameters: { + chromatic: { viewports: [390] }, + }, + beforeEach: () => mockDialogRequests({ acl: populatedACL }), + play: async ({ canvasElement }) => { + const body = await openChatSharing(canvasElement); + await waitFor(() => { + expect(body.getAllByText(chatUser.username).length).toBeGreaterThan(0); + expect(body.getAllByText(chatGroup.name).length).toBeGreaterThan(0); + expect(body.getAllByText("Read").length).toBeGreaterThan(0); + }); + expect( + body.getAllByRole("button", { name: "Open menu" }).length, + ).toBeGreaterThan(0); }, }; @@ -209,7 +239,7 @@ export const CurrentUserHidden: Story = { expect( body.queryByText(currentChatUser.username), ).not.toBeInTheDocument(); - expect(body.getByText(chatUser.username)).toBeVisible(); + expect(body.getAllByText(chatUser.username).length).toBeGreaterThan(0); }); }, }; @@ -307,7 +337,7 @@ export const RemoveUser: Story = { play: async ({ canvasElement }) => { const body = await openChatSharing(canvasElement); await waitFor(() => { - expect(body.getByText(chatUser.username)).toBeInTheDocument(); + expect(body.getAllByText(chatUser.username).length).toBeGreaterThan(0); }); // Groups render before users, so the user row menu is the second one. const menuButtons = await body.findAllByRole("button", { @@ -334,7 +364,7 @@ export const RemoveGroup: Story = { play: async ({ canvasElement }) => { const body = await openChatSharing(canvasElement); await waitFor(() => { - expect(body.getByText(chatGroup.name)).toBeInTheDocument(); + expect(body.getAllByText(chatGroup.name).length).toBeGreaterThan(0); }); // Groups render before users, so the group row menu is the first one. const menuButtons = await body.findAllByRole("button", { diff --git a/site/src/pages/AgentsPage/components/ChatSharingPopover.tsx b/site/src/pages/AgentsPage/components/ChatSharingPopover.tsx index 0a7720feb5..7fca07da7f 100644 --- a/site/src/pages/AgentsPage/components/ChatSharingPopover.tsx +++ b/site/src/pages/AgentsPage/components/ChatSharingPopover.tsx @@ -1,5 +1,5 @@ -import { EllipsisVerticalIcon, Share2Icon } from "lucide-react"; -import { type FC, useState } from "react"; +import { EllipsisVerticalIcon, Share2Icon, UserPlusIcon } from "lucide-react"; +import { type FC, type ReactNode, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { toast } from "sonner"; import { @@ -18,7 +18,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "#/components/DropdownMenu/DropdownMenu"; -import { EmptyState } from "#/components/EmptyState/EmptyState"; import { TopbarButton } from "#/components/FullPageLayout/Topbar"; import { Popover, @@ -40,7 +39,6 @@ import { UserOrGroupAutocomplete, type UserOrGroupAutocompleteValue, } from "#/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete"; -import { AddWorkspaceMemberForm } from "#/modules/workspaces/WorkspaceSharingForm/WorkspaceSharingForm"; type ChatShareButtonProps = { chatId: string; @@ -57,7 +55,7 @@ type MemberRowMenuProps = { }; const ReadRoleBadge: FC = () => ( - + Read ); @@ -85,6 +83,89 @@ const MemberRowMenu: FC = ({ disabled, onRemove }) => ( ); +type AddChatMemberFormProps = { + isLoading: boolean; + onSubmit: () => void; + disabled: boolean; + children: ReactNode; +}; + +const AddChatMemberForm: FC = ({ + isLoading, + onSubmit, + disabled, + children, +}) => ( +
+
+
{children}
+ +
+
+); + +type MemberIdentityProps = + | { kind: "group"; group: TypesGen.ChatGroup } + | { kind: "user"; user: TypesGen.ChatUser }; + +const MemberIdentity: FC = (props) => { + if (props.kind === "group") { + const { group } = props; + return ( + + } + /> + ); + } + + const { user } = props; + return ( + + ); +}; + +type MobileMemberRowProps = { + children: ReactNode; + disabled: boolean; + onRemove: () => void; +}; + +const MobileMemberRow: FC = ({ + children, + disabled, + onRemove, +}) => ( +
+
{children}
+
+ + +
+
+); + export const ChatSharingPopoverContent: FC = ({ chatId, organizationId, @@ -196,9 +277,12 @@ export const ChatSharingPopoverContent: FC = ({ const isEmpty = groups.length === 0 && users.length === 0; return ( - +
-

Chat Sharing

+

Chat sharing

@@ -212,7 +296,7 @@ export const ChatSharingPopoverContent: FC = ({
) : acl ? ( <> - = ({ onChange={setSelectedOption} organizationId={organizationId} exclude={excludeFromAutocomplete} + className="w-full sm:w-80" /> - + - - - - - Member - - - Role - - - - - - {isEmpty ? ( - - - - - - ) : ( - <> + {isEmpty ? ( +
+

+ No shared members or groups yet +

+

+ Add a member or group using the controls above. +

+
+ ) : ( +
+ {groups.map((group) => ( + handleRemoveGroup(group)} + > + + + ))} + {users.map((user) => ( + handleRemoveUser(user)} + > + + + ))} +
+ )} + + {!isEmpty && ( +
+
+ + + + Member + + + Role + + + + + {groups.map((group) => ( - - } - /> + @@ -283,11 +379,7 @@ export const ChatSharingPopoverContent: FC = ({ {users.map((user) => ( - + @@ -300,10 +392,10 @@ export const ChatSharingPopoverContent: FC = ({ ))} - - )} - -
+ + + + )} ) : null}