mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: polish agents UI (sidebar width, combobox, limits padding, back button) (#23204)
This commit is contained in:
@@ -7,15 +7,19 @@ import { ChevronDownIcon } from "components/AnimatedIcons/ChevronDown";
|
||||
import type { ModelSelectorOption } from "components/ai-elements";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
} from "components/Combobox/Combobox";
|
||||
import { MonitorIcon } from "lucide-react";
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "components/Command/Command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "components/Popover/Popover";
|
||||
import { Check, MonitorIcon } from "lucide-react";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import {
|
||||
type FC,
|
||||
@@ -191,6 +195,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
modelOptions.some((modelOption) => modelOption.id === userSelectedModel)
|
||||
? userSelectedModel
|
||||
: preferredModelID;
|
||||
const [workspacePopoverOpen, setWorkspacePopoverOpen] = useState(false);
|
||||
const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 }));
|
||||
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(
|
||||
() => {
|
||||
@@ -377,19 +382,17 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
uploadStates={uploadStates}
|
||||
previewUrls={previewUrls}
|
||||
leftActions={
|
||||
<Combobox
|
||||
value={selectedWorkspaceId ?? autoCreateWorkspaceValue}
|
||||
onValueChange={(value) =>
|
||||
handleWorkspaceChange(value ?? autoCreateWorkspaceValue)
|
||||
}
|
||||
<Popover
|
||||
open={workspacePopoverOpen}
|
||||
onOpenChange={setWorkspacePopoverOpen}
|
||||
>
|
||||
{/* pointer-events-auto overrides the pointer-events:none
|
||||
that Radix Select's DismissableLayer sets on
|
||||
document.body when the Model Selector is open.
|
||||
Without it the first click only dismisses the
|
||||
Select and a second click is needed to open
|
||||
the Combobox. */}
|
||||
<ComboboxTrigger asChild>
|
||||
that Radix Select's DismissableLayer sets on
|
||||
document.body when the Model Selector is open.
|
||||
Without it the first click only dismisses the
|
||||
Select and a second click is needed to open
|
||||
the popover. */}
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isCreating || workspacesQuery.isLoading}
|
||||
@@ -399,30 +402,45 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
<span>{selectedWorkspaceLabel ?? "Workspace"}</span>
|
||||
<ChevronDownIcon className="size-icon-sm text-content-secondary transition-colors group-hover:text-content-primary" />
|
||||
</button>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-72 bg-surface-primary border-border [&_[cmdk-root]]:bg-surface-primary [&_[cmdk-item]]:text-xs [&_[cmdk-item]>svg:last-child]:ml-auto"
|
||||
>
|
||||
<ComboboxInput placeholder="Search workspaces..." />
|
||||
<ComboboxList>
|
||||
<ComboboxItem value={autoCreateWorkspaceValue}>
|
||||
Auto-create Workspace
|
||||
</ComboboxItem>
|
||||
{workspaceOptions.map((workspace) => (
|
||||
<ComboboxItem
|
||||
key={workspace.id}
|
||||
value={workspace.id}
|
||||
keywords={[workspace.owner_name, workspace.name]}
|
||||
>
|
||||
{workspace.owner_name}/{workspace.name}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</ComboboxList>
|
||||
<ComboboxEmpty>No workspaces found</ComboboxEmpty>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="start" className="w-72 p-0">
|
||||
<Command loop>
|
||||
<CommandInput placeholder="Search workspaces..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No workspaces found</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="Auto-create Workspace"
|
||||
onSelect={() => {
|
||||
handleWorkspaceChange(autoCreateWorkspaceValue);
|
||||
setWorkspacePopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
Auto-create Workspace
|
||||
{selectedWorkspaceId == null && (
|
||||
<Check className="ml-auto size-icon-sm shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
{workspaceOptions.map((workspace) => (
|
||||
<CommandItem
|
||||
key={workspace.id}
|
||||
value={`${workspace.owner_name}/${workspace.name}`}
|
||||
onSelect={() => {
|
||||
handleWorkspaceChange(workspace.id);
|
||||
setWorkspacePopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
{workspace.owner_name}/{workspace.name}
|
||||
{selectedWorkspaceId === workspace.id && (
|
||||
<Check className="ml-auto size-icon-sm shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
}
|
||||
/>
|
||||
<p className="mt-1 text-center text-xs text-content-secondary/50">
|
||||
|
||||
@@ -709,7 +709,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
|
||||
aria-hidden={sidebarView.panel === "settings"}
|
||||
inert={sidebarView.panel === "settings" ? true : undefined}
|
||||
>
|
||||
<div className="hidden border-b border-border-default px-3 pb-3 pt-1.5 md:block md:px-3.5">
|
||||
<div className="hidden border-b border-border-default px-2 pb-3 pt-1.5 md:block">
|
||||
<div className="mb-2.5 flex items-center justify-between">
|
||||
<NavLink to="/workspaces" className="inline-flex">
|
||||
{logoUrl ? (
|
||||
|
||||
@@ -28,7 +28,6 @@ interface DefaultLimitSectionProps {
|
||||
onAmountDollarsChange: (amount: string) => void;
|
||||
unpricedModelCount: number;
|
||||
adminBadge: ReactNode;
|
||||
panelClassName: string;
|
||||
}
|
||||
|
||||
export const DefaultLimitSection: FC<DefaultLimitSectionProps> = ({
|
||||
@@ -40,20 +39,19 @@ export const DefaultLimitSection: FC<DefaultLimitSectionProps> = ({
|
||||
onAmountDollarsChange,
|
||||
unpricedModelCount,
|
||||
adminBadge,
|
||||
panelClassName,
|
||||
}) => {
|
||||
const periodId = useId();
|
||||
const amountId = useId();
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4">
|
||||
<SectionHeader
|
||||
label="Default Spend Limit"
|
||||
description="Set a deployment-wide spend cap that applies to all users by default."
|
||||
badge={adminBadge}
|
||||
/>
|
||||
|
||||
<div className={panelClassName}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="m-0 text-sm font-medium text-content-primary">
|
||||
@@ -134,6 +132,6 @@ export const DefaultLimitSection: FC<DefaultLimitSectionProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,7 +29,6 @@ interface GroupLimitsSectionProps {
|
||||
member_count: number;
|
||||
spend_limit_micros: number | null;
|
||||
}>;
|
||||
panelClassName: string;
|
||||
showGroupForm: boolean;
|
||||
onShowGroupFormChange: (show: boolean) => void;
|
||||
selectedGroup: Group | null;
|
||||
@@ -50,7 +49,6 @@ interface GroupLimitsSectionProps {
|
||||
|
||||
export const GroupLimitsSection: FC<GroupLimitsSectionProps> = ({
|
||||
groupOverrides,
|
||||
panelClassName,
|
||||
showGroupForm,
|
||||
onShowGroupFormChange,
|
||||
selectedGroup,
|
||||
@@ -78,7 +76,7 @@ export const GroupLimitsSection: FC<GroupLimitsSectionProps> = ({
|
||||
description="Override the default limit for specific groups. When a user belongs to multiple groups, the lowest group limit applies."
|
||||
/>
|
||||
|
||||
<div className={panelClassName}>
|
||||
<div className="space-y-4">
|
||||
{groupOverrides.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
||||
@@ -30,8 +30,6 @@ import { GroupLimitsSection } from "./GroupLimitsSection";
|
||||
import { normalizeChatUsageLimitPeriod } from "./limitsFormLogic";
|
||||
import { UserOverridesSection } from "./UserOverridesSection";
|
||||
|
||||
const sectionPanelClassName = "space-y-4 rounded-lg border border-border p-4";
|
||||
|
||||
interface DefaultLimitFormValues {
|
||||
enabled: boolean;
|
||||
period: ChatUsageLimitPeriod;
|
||||
@@ -305,8 +303,8 @@ export const LimitsTab: FC = () => {
|
||||
saveDefault,
|
||||
}) => (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 pb-24 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
|
||||
<div className="space-y-6">
|
||||
<div className="flex-1 overflow-y-auto pb-24 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
|
||||
<div className="space-y-10">
|
||||
<DefaultLimitSection
|
||||
adminBadge={<AdminBadge />}
|
||||
enabled={enabled}
|
||||
@@ -325,12 +323,9 @@ export const LimitsTab: FC = () => {
|
||||
onAmountDollarsChange(nextAmountDollars);
|
||||
}}
|
||||
unpricedModelCount={unpricedModelCount}
|
||||
panelClassName={sectionPanelClassName}
|
||||
/>
|
||||
|
||||
<GroupLimitsSection
|
||||
groupOverrides={groupOverrides}
|
||||
panelClassName={sectionPanelClassName}
|
||||
showGroupForm={showGroupForm}
|
||||
onShowGroupFormChange={setShowGroupForm}
|
||||
selectedGroup={selectedGroup}
|
||||
@@ -358,10 +353,8 @@ export const LimitsTab: FC = () => {
|
||||
}
|
||||
groupsError={groupsQuery.isError ? groupsQuery.error : null}
|
||||
/>
|
||||
|
||||
<UserOverridesSection
|
||||
overrides={overrides}
|
||||
panelClassName={sectionPanelClassName}
|
||||
showUserForm={showUserForm}
|
||||
onShowUserFormChange={setShowUserForm}
|
||||
selectedUser={selectedUser}
|
||||
@@ -387,7 +380,7 @@ export const LimitsTab: FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 flex shrink-0 flex-col gap-2 border-t border-border bg-surface-primary px-6 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="sticky bottom-0 flex shrink-0 flex-col gap-2 border-t border-border bg-surface-primary py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-h-4 text-xs">
|
||||
{updateConfigMutation.isError && (
|
||||
<p className="m-0 text-content-destructive">
|
||||
|
||||
@@ -26,7 +26,6 @@ interface UserOverridesSectionProps {
|
||||
avatar_url: string;
|
||||
spend_limit_micros: number | null;
|
||||
}>;
|
||||
panelClassName: string;
|
||||
showUserForm: boolean;
|
||||
onShowUserFormChange: (show: boolean) => void;
|
||||
selectedUser: User | null;
|
||||
@@ -44,7 +43,6 @@ interface UserOverridesSectionProps {
|
||||
|
||||
export const UserOverridesSection: FC<UserOverridesSectionProps> = ({
|
||||
overrides,
|
||||
panelClassName,
|
||||
showUserForm,
|
||||
onShowUserFormChange,
|
||||
selectedUser,
|
||||
@@ -68,7 +66,7 @@ export const UserOverridesSection: FC<UserOverridesSectionProps> = ({
|
||||
description="Override the deployment default spend limit for specific users. User overrides take highest priority, followed by group limits, then the deployment default."
|
||||
/>
|
||||
|
||||
<div className={panelClassName}>
|
||||
<div className="space-y-4">
|
||||
{overrides.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
import dayjs from "dayjs";
|
||||
import { useDebouncedValue } from "hooks/debounce";
|
||||
import { useClickableTableRow } from "hooks/useClickableTableRow";
|
||||
import { FlaskConicalIcon, ShieldIcon } from "lucide-react";
|
||||
import { ChevronLeftIcon, FlaskConicalIcon, ShieldIcon } from "lucide-react";
|
||||
import { type FC, type FormEvent, useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
keepPreviousData,
|
||||
@@ -182,20 +182,9 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
|
||||
}
|
||||
badge={<AdminBadge />}
|
||||
action={
|
||||
selectedUser ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setSelectedUser(null)}
|
||||
>
|
||||
← Back to all users
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-content-secondary">
|
||||
{dateRange.rangeLabel}
|
||||
</span>
|
||||
)
|
||||
<span className="text-xs text-content-secondary">
|
||||
{dateRange.rangeLabel}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -203,7 +192,17 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
|
||||
if (selectedUser) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{header}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedUser(null)}
|
||||
className="mb-4 inline-flex cursor-pointer items-center gap-0.5 bg-transparent border-0 p-0 text-sm text-content-secondary transition-colors hover:text-content-primary"
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
Back
|
||||
</button>
|
||||
{header}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-border-default bg-surface-secondary px-4 py-3">
|
||||
<AvatarData
|
||||
title={selectedUser.name || selectedUser.username}
|
||||
|
||||
Reference in New Issue
Block a user