mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
fix(site): show cross-org workspaces as disabled in chat picker (#24944)
All user workspaces now appear in the picker. Workspaces from a different organization are rendered as disabled (greyed out, not selectable) with a tooltip on hover: "Chat and workspace must be in the same organization."
This commit is contained in:
@@ -1,11 +1,9 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import {
|
||||
clearPersistedSidebarTabId,
|
||||
draftInputStorageKeyPrefix,
|
||||
filterWorkspaceOptionsByOrganization,
|
||||
getPersistedDraftInputValue,
|
||||
getPersistedSidebarTabId,
|
||||
lastActiveSidebarTabStorageKeyPrefix,
|
||||
@@ -128,32 +126,6 @@ describe("waitForPendingChatSettingsSyncs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterWorkspaceOptionsByOrganization", () => {
|
||||
const makeWorkspace = (id: string, organizationID: string) =>
|
||||
({ id, organization_id: organizationID }) as TypesGen.Workspace;
|
||||
|
||||
it("returns only workspaces from the active chat organization", () => {
|
||||
const workspaces = [
|
||||
makeWorkspace("workspace-1", "org-a"),
|
||||
makeWorkspace("workspace-2", "org-b"),
|
||||
makeWorkspace("workspace-3", "org-a"),
|
||||
];
|
||||
|
||||
expect(filterWorkspaceOptionsByOrganization(workspaces, "org-a")).toEqual([
|
||||
workspaces[0],
|
||||
workspaces[2],
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty list until the chat organization is known", () => {
|
||||
const workspaces = [makeWorkspace("workspace-1", "org-a")];
|
||||
|
||||
expect(filterWorkspaceOptionsByOrganization(workspaces, undefined)).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPersistedDraftInputValue", () => {
|
||||
const chatID = "chat-abc-123";
|
||||
const expectedKey = `${draftInputStorageKeyPrefix}${chatID}`;
|
||||
|
||||
@@ -710,10 +710,7 @@ const AgentChatPage: FC = () => {
|
||||
const userDebugLoggingQuery = useQuery(userChatDebugLogging());
|
||||
const mcpServersQuery = useQuery(mcpServerConfigs());
|
||||
const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 }));
|
||||
const workspaceOptions = filterWorkspaceOptionsByOrganization(
|
||||
workspacesQuery.data?.workspaces ?? [],
|
||||
chatQuery.data?.organization_id,
|
||||
);
|
||||
const workspaceOptions = workspacesQuery.data?.workspaces ?? [];
|
||||
const desktopEnabled = desktopEnabledQuery.data?.enable_desktop ?? false;
|
||||
const debugLoggingEnabled =
|
||||
userDebugLoggingQuery.data?.debug_logging_enabled ?? false;
|
||||
|
||||
@@ -541,6 +541,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
onPlanModeToggle={onPlanModeToggle}
|
||||
isModelCatalogLoading={isModelCatalogLoading}
|
||||
workspaceOptions={workspaceOptions}
|
||||
chatOrganizationId={organizationId}
|
||||
selectedWorkspaceId={selectedWorkspaceId}
|
||||
onWorkspaceChange={onWorkspaceChange}
|
||||
isWorkspaceLoading={isWorkspaceLoading}
|
||||
|
||||
@@ -720,6 +720,7 @@ export const DetailPageWorkspacePicker: Story = {
|
||||
id: "ws-detail",
|
||||
name: "agents-workspace",
|
||||
owner_name: "mike",
|
||||
organization_id: "org-1",
|
||||
},
|
||||
],
|
||||
selectedWorkspaceId: "ws-detail",
|
||||
@@ -810,7 +811,12 @@ export const OverflowBadges: Story = {
|
||||
pagerdutyMCP.id,
|
||||
],
|
||||
workspaceOptions: [
|
||||
{ id: "ws-1", name: "my-long-workspace-name", owner_name: "admin" },
|
||||
{
|
||||
id: "ws-1",
|
||||
name: "my-long-workspace-name",
|
||||
owner_name: "admin",
|
||||
organization_id: "org-1",
|
||||
},
|
||||
],
|
||||
selectedWorkspaceId: "ws-1",
|
||||
onWorkspaceChange: fn(),
|
||||
|
||||
@@ -122,9 +122,13 @@ interface AgentChatInputProps {
|
||||
id: string;
|
||||
name: string;
|
||||
owner_name: string;
|
||||
organization_id: string;
|
||||
}>;
|
||||
selectedWorkspaceId?: string | null;
|
||||
onWorkspaceChange?: (id: string | null) => void;
|
||||
// Organization ID of the current chat. When set, workspaces from
|
||||
// other organizations are shown as disabled in the picker.
|
||||
chatOrganizationId?: string;
|
||||
isWorkspaceLoading?: boolean;
|
||||
// Queued user messages rendered above the textarea.
|
||||
queuedMessages?: readonly ChatQueuedMessage[];
|
||||
@@ -298,6 +302,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
workspaceOptions,
|
||||
selectedWorkspaceId,
|
||||
onWorkspaceChange,
|
||||
chatOrganizationId,
|
||||
isWorkspaceLoading,
|
||||
queuedMessages = [],
|
||||
onDeleteQueuedMessage,
|
||||
@@ -882,35 +887,15 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<Separator className="my-1" />
|
||||
<Command loop>
|
||||
<CommandInput
|
||||
placeholder="Search workspaces..."
|
||||
className="text-xs"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">
|
||||
No workspaces found
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{workspaceOptions?.map((workspace) => (
|
||||
<CommandItem
|
||||
className="text-xs font-normal"
|
||||
key={workspace.id}
|
||||
value={workspace.name}
|
||||
onSelect={() => {
|
||||
onWorkspaceChange?.(workspace.id);
|
||||
setPlusMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{workspace.name}
|
||||
{selectedWorkspaceId === workspace.id && (
|
||||
<CheckIcon className="ml-auto size-icon-sm shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
<WorkspacePickerList
|
||||
workspaceOptions={workspaceOptions}
|
||||
selectedWorkspaceId={selectedWorkspaceId}
|
||||
chatOrganizationId={chatOrganizationId}
|
||||
onSelect={(id) => {
|
||||
onWorkspaceChange?.(id);
|
||||
setPlusMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -983,36 +968,16 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
sideOffset={8}
|
||||
className="w-64 p-0"
|
||||
>
|
||||
<Command loop>
|
||||
<CommandInput
|
||||
placeholder="Search workspaces..."
|
||||
className="text-xs"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">
|
||||
No workspaces found
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{workspaceOptions.map((workspace) => (
|
||||
<CommandItem
|
||||
className="text-xs font-normal"
|
||||
key={workspace.id}
|
||||
value={workspace.name}
|
||||
onSelect={() => {
|
||||
onWorkspaceChange(workspace.id);
|
||||
setWorkspacePickerOpen(false);
|
||||
setPlusMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{workspace.name}
|
||||
{selectedWorkspaceId === workspace.id && (
|
||||
<CheckIcon className="ml-auto size-icon-sm shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
<WorkspacePickerList
|
||||
workspaceOptions={workspaceOptions}
|
||||
selectedWorkspaceId={selectedWorkspaceId}
|
||||
chatOrganizationId={chatOrganizationId}
|
||||
onSelect={(id) => {
|
||||
onWorkspaceChange(id);
|
||||
setWorkspacePickerOpen(false);
|
||||
setPlusMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
))}
|
||||
@@ -1283,3 +1248,82 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared workspace picker used by both the mobile and desktop
|
||||
* "Attach workspace" menus. Workspaces from a different organization
|
||||
* than the chat are rendered as disabled items with a tooltip.
|
||||
*/
|
||||
interface WorkspacePickerListProps {
|
||||
workspaceOptions:
|
||||
| ReadonlyArray<{
|
||||
id: string;
|
||||
name: string;
|
||||
organization_id: string;
|
||||
}>
|
||||
| undefined;
|
||||
selectedWorkspaceId?: string | null;
|
||||
chatOrganizationId?: string;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
const WorkspacePickerList: FC<WorkspacePickerListProps> = ({
|
||||
workspaceOptions,
|
||||
selectedWorkspaceId,
|
||||
chatOrganizationId,
|
||||
onSelect,
|
||||
}) => {
|
||||
return (
|
||||
<Command loop>
|
||||
<CommandInput placeholder="Search workspaces..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">No workspaces found</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{workspaceOptions?.map((workspace) => {
|
||||
const isCrossOrg =
|
||||
!!chatOrganizationId &&
|
||||
workspace.organization_id !== chatOrganizationId;
|
||||
|
||||
const item = (
|
||||
<CommandItem
|
||||
className={cn(
|
||||
"text-xs font-normal",
|
||||
isCrossOrg &&
|
||||
"cursor-not-allowed opacity-50 data-[disabled=true]:pointer-events-auto",
|
||||
)}
|
||||
key={workspace.id}
|
||||
value={workspace.name}
|
||||
disabled={isCrossOrg}
|
||||
onSelect={() => {
|
||||
if (!isCrossOrg) {
|
||||
onSelect(workspace.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{workspace.name}
|
||||
{selectedWorkspaceId === workspace.id && (
|
||||
<CheckIcon className="ml-auto size-icon-sm shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
|
||||
if (isCrossOrg) {
|
||||
return (
|
||||
<Tooltip key={workspace.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{item}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Chat and workspace must be in the same organization
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return item;
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -201,6 +201,7 @@ interface ChatPageInputProps {
|
||||
onMCPAuthComplete?: (serverId: string) => void;
|
||||
lastInjectedContext?: readonly TypesGen.ChatMessagePart[];
|
||||
workspaceOptions: readonly TypesGen.Workspace[];
|
||||
chatOrganizationId?: string;
|
||||
selectedWorkspaceId: string | null;
|
||||
onWorkspaceChange: (workspaceId: string | null) => void;
|
||||
isWorkspaceLoading: boolean;
|
||||
@@ -252,6 +253,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
|
||||
onMCPAuthComplete,
|
||||
lastInjectedContext,
|
||||
workspaceOptions,
|
||||
chatOrganizationId,
|
||||
selectedWorkspaceId,
|
||||
onWorkspaceChange,
|
||||
isWorkspaceLoading,
|
||||
@@ -481,6 +483,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
|
||||
onPlanModeToggle={onPlanModeToggle}
|
||||
isModelCatalogLoading={isModelCatalogLoading}
|
||||
workspaceOptions={workspaceOptions}
|
||||
chatOrganizationId={chatOrganizationId}
|
||||
selectedWorkspaceId={selectedWorkspaceId}
|
||||
onWorkspaceChange={onWorkspaceChange}
|
||||
isWorkspaceLoading={isWorkspaceLoading}
|
||||
|
||||
Reference in New Issue
Block a user