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:
Steven Masley
2026-05-05 09:07:54 -05:00
committed by GitHub
parent 5322755691
commit 9b4666020b
6 changed files with 115 additions and 92 deletions
@@ -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}`;
+1 -4
View File
@@ -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}