feat(site): make org selector compact (#24318)

This commit is contained in:
Danielle Maywood
2026-04-15 15:22:41 +00:00
committed by GitHub
parent 517bb1f9f7
commit 93a1a5145a
6 changed files with 231 additions and 33 deletions
@@ -338,9 +338,7 @@ export const WithOrganizationPicker: Story = {
const canvas = within(canvasElement);
// Verify the org picker rendered (component didn't crash).
await waitFor(() => {
expect(
canvas.getByTestId("organization-autocomplete"),
).toBeInTheDocument();
expect(canvas.getByTestId("compact-org-selector")).toBeInTheDocument();
});
// Type into the chat input to trigger re-renders. If the
// permittedOrgs fallback is referentially unstable, this
@@ -349,7 +347,7 @@ export const WithOrganizationPicker: Story = {
await userEvent.click(input);
await userEvent.keyboard("hello world");
// The org picker should still be present after typing.
expect(canvas.getByTestId("organization-autocomplete")).toBeInTheDocument();
expect(canvas.getByTestId("compact-org-selector")).toBeInTheDocument();
},
};
@@ -9,8 +9,6 @@ import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
import { Label } from "#/components/Label/Label";
import { OrganizationAutocomplete } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { docs } from "#/utils/docs";
import { useFileAttachments } from "../hooks/useFileAttachments";
@@ -27,6 +25,7 @@ import {
import { AgentChatInput } from "./AgentChatInput";
import { ChatAccessDeniedAlert } from "./ChatAccessDeniedAlert";
import type { ModelSelectorOption } from "./ChatElements";
import { CompactOrgSelector } from "./ChatElements";
import {
getDefaultMCPSelection,
getSavedMCPSelection,
@@ -433,30 +432,23 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
{permittedOrgsQuery.error != null && (
<ErrorAlert error={permittedOrgsQuery.error} />
)}
{showOrganizations &&
!permittedOrgsQuery.isLoading &&
permittedOrgs.length > 1 && (
<div className="flex flex-col gap-2">
<Label htmlFor="organization">Organization</Label>
<OrganizationAutocomplete
id="organization"
required
value={selectedOrg}
options={permittedOrgs}
onChange={(newOrg) => {
const orgChanged = newOrg?.id !== selectedOrg?.id;
if (orgChanged && attachments.length > 0) {
setPendingOrgChange(newOrg);
return;
}
if (orgChanged) {
handleWorkspaceChange(null);
}
setSelectedOrg(newOrg);
}}
/>
</div>
)}
{showOrganizations && permittedOrgs.length > 1 && (
<CompactOrgSelector
value={selectedOrg}
options={permittedOrgs}
onChange={(newOrg) => {
const orgChanged = newOrg.id !== selectedOrg?.id;
if (orgChanged && attachments.length > 0) {
setPendingOrgChange(newOrg);
return;
}
if (orgChanged) {
handleWorkspaceChange(null);
}
setSelectedOrg(newOrg);
}}
/>
)}
<AgentChatInput
onSend={handleSendWithAttachments}
placeholder="Ask Coder to build, fix bugs, or explore your project..."
@@ -0,0 +1,79 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import type { Organization } from "#/api/typesGenerated";
import { CompactOrgSelector } from "./CompactOrgSelector";
const mockOrgs: Organization[] = [
{
id: "org-coder",
name: "coder",
display_name: "Coder",
icon: "/icon/coder.svg",
description: "Main engineering organization",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-06-01T00:00:00Z",
is_default: true,
},
{
id: "org-acme",
name: "acme-corp",
display_name: "Acme Corp",
icon: "",
description: "Acme Corporation",
created_at: "2024-02-01T00:00:00Z",
updated_at: "2024-06-01T00:00:00Z",
is_default: false,
},
{
id: "org-globex",
name: "globex",
display_name: "Globex Inc",
icon: "",
description: "Globex Incorporated",
created_at: "2024-03-01T00:00:00Z",
updated_at: "2024-06-01T00:00:00Z",
is_default: false,
},
];
const meta: Meta<typeof CompactOrgSelector> = {
title: "pages/AgentsPage/ChatElements/CompactOrgSelector",
component: CompactOrgSelector,
decorators: [
(Story) => (
<div className="w-72 rounded-lg border border-solid border-border-default bg-surface-primary p-4">
<Story />
</div>
),
],
args: {
options: mockOrgs,
value: mockOrgs[0],
onChange: fn(),
},
};
export default meta;
type Story = StoryObj<typeof CompactOrgSelector>;
export const Default: Story = {};
export const Disabled: Story = {
args: {
disabled: true,
value: mockOrgs[0],
},
};
export const NoSelection: Story = {
args: {
value: null,
},
};
export const SingleOption: Story = {
args: {
options: [mockOrgs[0]],
value: mockOrgs[0],
},
};
@@ -0,0 +1,126 @@
import { Check } from "lucide-react";
import { type FC, useState } from "react";
import type { Organization } from "#/api/typesGenerated";
import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
import { Avatar } from "#/components/Avatar/Avatar";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "#/components/Command/Command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "#/components/Popover/Popover";
import { cn } from "#/utils/cn";
interface CompactOrgSelectorProps {
value: Organization | null;
onChange?: (organization: Organization) => void;
options: readonly Organization[];
disabled?: boolean;
className?: string;
dropdownSide?: "top" | "bottom" | "left" | "right";
dropdownAlign?: "start" | "center" | "end";
}
export const CompactOrgSelector: FC<CompactOrgSelectorProps> = ({
value,
onChange,
options,
disabled = false,
className,
dropdownSide = "bottom",
dropdownAlign = "start",
}) => {
const [open, setOpen] = useState(false);
const isDisabled = disabled || options.length === 0;
return (
<Popover open={open} onOpenChange={isDisabled ? undefined : setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={isDisabled}
data-testid="compact-org-selector"
aria-label={
value
? `Organization: ${value.display_name || value.name}`
: "Select organization"
}
className={cn(
"group flex h-6 w-auto cursor-pointer items-center gap-1.5 border-none bg-transparent px-1 text-xs text-content-secondary shadow-none whitespace-nowrap transition-colors",
"hover:text-content-primary focus:ring-0",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
>
{value ? (
<>
<Avatar
size="sm"
src={value.icon}
fallback={value.display_name || value.name}
className="!size-3.5 border-0"
/>
<span className="truncate">
{value.display_name || value.name}
</span>
</>
) : (
<span>Select org…</span>
)}
<ChevronDownIcon
open={open}
className="size-icon-sm shrink-0 text-content-secondary transition-colors hover:text-content-primary group-hover:text-content-primary"
/>
</button>
</PopoverTrigger>
<PopoverContent
side={dropdownSide}
align={dropdownAlign}
className="w-64 p-0"
>
<Command loop>
<CommandInput placeholder="Find organization…" className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs">
No organizations found
</CommandEmpty>
<CommandGroup>
{options.map((org) => (
<CommandItem
className="text-xs font-normal"
key={org.id}
value={`${org.display_name} ${org.name}`}
onSelect={() => {
onChange?.(org);
setOpen(false);
}}
>
{" "}
<Avatar
size="sm"
src={org.icon}
fallback={org.display_name || org.name}
className="!size-3.5 border-0"
/>
<span className="truncate">
{org.display_name || org.name}
</span>
{value?.id === org.id && (
<Check className="ml-auto size-icon-sm shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
@@ -1,3 +1,4 @@
export { CompactOrgSelector } from "./CompactOrgSelector";
export { ConversationItem } from "./Conversation";
export { Message, MessageContent } from "./Message";
export type { ModelSelectorOption } from "./ModelSelector";
@@ -422,9 +422,11 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
return (
<div>
{inputElement}
<div className="px-3 pt-1 text-2xs text-content-secondary">
{modelSelectorHelp}
</div>
{modelSelectorHelp && (
<div className="px-3 pt-1 text-2xs text-content-secondary">
{modelSelectorHelp}
</div>
)}
</div>
);
};