feat: add workspace sharing buttons to tasks (#21491)

resolves coder/internal#1130

This adds a workspace sharing button to tasks in 3 places

Figma:
https://www.figma.com/design/KriBGfS73GAwkplnVhCBoU/Tasks?node-id=278-2455&t=vhU6Q8G1b7fDWiAP-1

<img width="320" height="374" alt="Screenshot 2026-01-13 at 15 16 06"
src="https://github.com/user-attachments/assets/cf232a12-b0c8-4f5c-91fa-d84eac8cb106"
/>
<img width="582" height="372" alt="Screenshot 2026-01-13 at 15 16 36"
src="https://github.com/user-attachments/assets/90654afc-720a-4bfe-9c67-fcbcebb4aa2b"
/>
<img width="768" height="317" alt="Screenshot 2026-01-13 at 15 18 03"
src="https://github.com/user-attachments/assets/0281cb84-c941-4075-9a20-00ad3958864b"
/>
This commit is contained in:
Jaayden Halko
2026-01-14 23:21:44 +00:00
committed by GitHub
parent 0712faef4f
commit e1156b050f
6 changed files with 129 additions and 43 deletions
@@ -7,6 +7,7 @@ import {
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { CoderIcon } from "components/Icons/CoderIcon";
@@ -21,7 +22,13 @@ import {
} from "components/Tooltip/Tooltip";
import { useAuthenticated } from "hooks";
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
import { EditIcon, EllipsisIcon, PanelLeftIcon, TrashIcon } from "lucide-react";
import {
EditIcon,
EllipsisIcon,
PanelLeftIcon,
Share2Icon,
TrashIcon,
} from "lucide-react";
import { type FC, useState } from "react";
import { useQuery } from "react-query";
import { Link as RouterLink, useNavigate, useParams } from "react-router";
@@ -227,6 +234,15 @@ const TaskSidebarMenuItem: FC<TaskSidebarMenuItemProps> = ({ task }) => {
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<RouterLink
to={`/@${task.owner_name}/${task.workspace_name}/settings/sharing`}
>
<Share2Icon />
Share workspace
</RouterLink>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={(e) => {
@@ -235,7 +251,7 @@ const TaskSidebarMenuItem: FC<TaskSidebarMenuItemProps> = ({ task }) => {
}}
>
<TrashIcon />
Delete
Delete&hellip;
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
+7 -1
View File
@@ -5,6 +5,7 @@ import { workspaceBuildParameters } from "api/queries/workspaceBuilds";
import {
startWorkspace,
workspaceByOwnerAndName,
workspacePermissions,
} from "api/queries/workspaces";
import type {
Workspace,
@@ -78,6 +79,7 @@ const TaskPage = () => {
return state.error ? false : 5_000;
},
});
const { data: permissions } = useQuery(workspacePermissions(workspace));
const refetch = taskQuery.error ? taskQuery.refetch : workspaceQuery.refetch;
const error = taskQuery.error ?? workspaceQuery.error;
const waitingStatuses: WorkspaceStatus[] = ["starting", "pending"];
@@ -198,7 +200,11 @@ const TaskPage = () => {
<TaskPageLayout>
<title>{pageTitle(task.display_name)}</title>
<TaskTopbar task={task} workspace={workspace} />
<TaskTopbar
task={task}
workspace={workspace}
canUpdatePermissions={permissions?.updateWorkspace ?? false}
/>
{content}
<ModifyPromptDialog
+22 -8
View File
@@ -11,17 +11,26 @@ import {
ArrowLeftIcon,
CheckIcon,
CopyIcon,
LaptopMinimalIcon,
TerminalIcon,
LayoutPanelTopIcon,
SquareTerminalIcon,
} from "lucide-react";
import type { FC } from "react";
import { Link as RouterLink } from "react-router";
import { ShareButton } from "../WorkspacePage/WorkspaceActions/ShareButton";
import { TaskStartupWarningButton } from "./TaskStartupWarningButton";
import { TaskStatusLink } from "./TaskStatusLink";
type TaskTopbarProps = { task: Task; workspace: Workspace };
type TaskTopbarProps = {
task: Task;
workspace: Workspace;
canUpdatePermissions: boolean;
};
export const TaskTopbar: FC<TaskTopbarProps> = ({ task, workspace }) => {
export const TaskTopbar: FC<TaskTopbarProps> = ({
task,
workspace,
canUpdatePermissions,
}) => {
return (
<header className="flex flex-shrink-0 items-center gap-2 p-3 border-solid border-border border-0 border-b">
<TooltipProvider>
@@ -57,8 +66,8 @@ export const TaskTopbar: FC<TaskTopbarProps> = ({ task, workspace }) => {
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm">
<TerminalIcon />
Prompt
<SquareTerminalIcon />
View Prompt
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs bg-surface-secondary p-4">
@@ -70,10 +79,15 @@ export const TaskTopbar: FC<TaskTopbarProps> = ({ task, workspace }) => {
</Tooltip>
</TooltipProvider>
<ShareButton
workspace={workspace}
canUpdatePermissions={canUpdatePermissions}
/>
<Button asChild variant="outline" size="sm">
<RouterLink to={`/@${workspace.owner_name}/${workspace.name}`}>
<LaptopMinimalIcon />
Workspace
<LayoutPanelTopIcon />
Go to workspace
</RouterLink>
</Button>
</div>
+31 -4
View File
@@ -16,7 +16,15 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { getTemplatesQueryKey } from "api/queries/templates";
import { MockUsers } from "pages/UsersPage/storybookData/users";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import {
expect,
fireEvent,
screen,
spyOn,
userEvent,
waitFor,
within,
} from "storybook/test";
import TasksPage from "./TasksPage";
const meta: Meta<typeof TasksPage> = {
@@ -232,6 +240,20 @@ export const NonAdmin: Story = {
},
};
export const OpenKebabMenu: Story = {
beforeEach: () => {
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
spyOn(API, "getTasks").mockResolvedValue(MockTasks);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const kebabButtons = await canvas.findAllByRole("button", {
name: /open task actions/i,
});
await userEvent.click(kebabButtons[0]);
},
};
export const OpenDeleteDialog: Story = {
beforeEach: () => {
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
@@ -239,10 +261,15 @@ export const OpenDeleteDialog: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const deleteButtons = await canvas.findAllByRole("button", {
name: /delete task/i,
const kebabButtons = await canvas.findAllByRole("button", {
name: /open task actions/i,
});
await userEvent.click(deleteButtons[0]);
await userEvent.click(kebabButtons[0]);
const deleteMenuItem = await screen.findByRole("menuitem", {
name: /delete/i,
});
fireEvent.click(deleteMenuItem);
},
};
+48 -25
View File
@@ -5,6 +5,13 @@ import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/Avatar/AvatarData";
import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton";
import { Button } from "components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { Skeleton } from "components/Skeleton/Skeleton";
import {
Table,
@@ -18,14 +25,13 @@ import {
TableLoaderSkeleton,
TableRowSkeleton,
} from "components/TableLoader/TableLoader";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { useClickableTableRow } from "hooks";
import { RotateCcwIcon, TrashIcon } from "lucide-react";
import {
EllipsisVertical,
RotateCcwIcon,
Share2Icon,
TrashIcon,
} from "lucide-react";
import { TaskDeleteDialog } from "modules/tasks/TaskDeleteDialog/TaskDeleteDialog";
import { TaskStatus } from "modules/tasks/TaskStatus/TaskStatus";
import { type FC, type ReactNode, useState } from "react";
@@ -255,24 +261,41 @@ const TaskRow: FC<TaskRowProps> = ({
/>
</TableCell>
<TableCell className="text-right">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={(e) => {
e.stopPropagation();
setIsDeleteDialogOpen(true);
}}
>
<span className="sr-only">Delete task</span>
<TrashIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Delete task</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
variant="subtle"
onClick={(e) => e.stopPropagation()}
>
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Open task actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => {
navigate(
`/@${task.owner_name}/${task.workspace_name}/settings/sharing`,
);
}}
>
<Share2Icon />
Share workspace
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={(e) => {
e.stopPropagation();
setIsDeleteDialogOpen(true);
}}
>
<TrashIcon />
Delete&hellip;
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
@@ -5,7 +5,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
import { UsersIcon } from "lucide-react";
import { Share2Icon } from "lucide-react";
import { isGroup } from "modules/groups";
import { AddWorkspaceUserOrGroup } from "modules/workspaces/WorkspaceSharingForm/AddWorkspaceUserOrGroup";
import { useWorkspaceSharing } from "modules/workspaces/WorkspaceSharingForm/useWorkspaceSharing";
@@ -27,8 +27,8 @@ export const ShareButton: FC<ShareButtonProps> = ({
<Popover>
<PopoverTrigger asChild>
<TopbarButton data-testid="workspace-share-button">
<UsersIcon />
Share
<Share2Icon />
Share Workspace
</TopbarButton>
</PopoverTrigger>
<PopoverContent align="end" className="w-[580px] p-4">