mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
feat(site/src/pages/AgentsPage): add organization filter to compaction settings (#28559)
Adds an organization picker above the compaction thresholds table on `/agents/settings/compaction`, matching the organization dropdowns in the agents admin area (`OrganizationAutocomplete`, as used by the Models and MCP Servers pages). The picker only appears when enabled models span more than one organization, defaults to the default organization, and the table shows the selected organization's models. Save tracking still covers all models so an edited row hidden by the picker is not dropped. Also removes the organization name text under each model badge (the organization remains in the accessible labels to disambiguate duplicate model names) and changes the model badge size from `sm` to `md`. _This PR was generated by Coder Agents on behalf of @tracyjohnsonux._
This commit is contained in:
@@ -43,14 +43,7 @@ const AgentSettingsCompactionPage: FC = () => {
|
||||
<AgentSettingsCompactionPageView
|
||||
models={organizationModels.models}
|
||||
providerTypeByID={providerTypeByID}
|
||||
organizationNameByID={
|
||||
new Map(
|
||||
organizations.map((organization) => [
|
||||
organization.id,
|
||||
organization.display_name || organization.name,
|
||||
]),
|
||||
)
|
||||
}
|
||||
organizations={organizations}
|
||||
modelsError={organizationModels.error ?? organizationModels.partialError}
|
||||
isLoadingModels={organizationModels.isLoading}
|
||||
thresholds={thresholdsQuery.data?.thresholds}
|
||||
|
||||
@@ -21,9 +21,7 @@ const baseArgs: AgentSettingsCompactionPageViewProps = {
|
||||
},
|
||||
],
|
||||
providerTypeByID: new Map<string, string>([["prov-openai", "openai"]]),
|
||||
organizationNameByID: new Map<string, string>([
|
||||
[MockDefaultOrganization.id, MockDefaultOrganization.display_name],
|
||||
]),
|
||||
organizations: [MockDefaultOrganization],
|
||||
modelsError: undefined,
|
||||
isLoadingModels: false,
|
||||
thresholds: [
|
||||
|
||||
@@ -6,7 +6,7 @@ import { UserCompactionThresholdSettings } from "./components/UserCompactionThre
|
||||
export interface AgentSettingsCompactionPageViewProps {
|
||||
models: readonly TypesGen.ChatModel[] | undefined;
|
||||
providerTypeByID: ReadonlyMap<string, string>;
|
||||
organizationNameByID: ReadonlyMap<string, string>;
|
||||
organizations: readonly TypesGen.Organization[];
|
||||
modelsError: unknown;
|
||||
isLoadingModels: boolean;
|
||||
thresholds: readonly TypesGen.UserChatCompactionThreshold[] | undefined;
|
||||
@@ -24,7 +24,7 @@ export const AgentSettingsCompactionPageView: FC<
|
||||
> = ({
|
||||
models,
|
||||
providerTypeByID,
|
||||
organizationNameByID,
|
||||
organizations,
|
||||
modelsError,
|
||||
isLoadingModels,
|
||||
thresholds,
|
||||
@@ -42,7 +42,7 @@ export const AgentSettingsCompactionPageView: FC<
|
||||
<UserCompactionThresholdSettings
|
||||
models={models ?? []}
|
||||
providerTypeByID={providerTypeByID}
|
||||
organizationNameByID={organizationNameByID}
|
||||
organizations={organizations}
|
||||
modelsError={modelsError}
|
||||
isLoadingModels={isLoadingModels}
|
||||
thresholds={thresholds}
|
||||
|
||||
@@ -2,13 +2,22 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { MockChatModel } from "#/testHelpers/chatModels";
|
||||
import { MockDefaultOrganization, MockUserOwner } from "#/testHelpers/entities";
|
||||
import {
|
||||
MockDefaultOrganization,
|
||||
MockOrganization2,
|
||||
MockUserOwner,
|
||||
} from "#/testHelpers/entities";
|
||||
import {
|
||||
withAuthProvider,
|
||||
withDashboardProvider,
|
||||
} from "#/testHelpers/storybook";
|
||||
import { UserCompactionThresholdSettings } from "./UserCompactionThresholdSettings";
|
||||
|
||||
const modelsOrganization = {
|
||||
...MockDefaultOrganization,
|
||||
id: MockChatModel.organization_id,
|
||||
};
|
||||
|
||||
const organizationWithEmptyDisplayName = {
|
||||
...MockDefaultOrganization,
|
||||
id: MockChatModel.organization_id,
|
||||
@@ -59,9 +68,7 @@ const meta = {
|
||||
["provider-1", "openai"],
|
||||
["provider-anthropic", "anthropic"],
|
||||
]),
|
||||
organizationNameByID: new Map<string, string>([
|
||||
[MockChatModel.organization_id, MockDefaultOrganization.display_name],
|
||||
]),
|
||||
organizations: [modelsOrganization],
|
||||
thresholds: [],
|
||||
isThresholdsLoading: false,
|
||||
thresholdsError: undefined,
|
||||
@@ -116,20 +123,11 @@ export const Default: Story = {
|
||||
|
||||
export const EmptyOrganizationDisplayNameFallsBackToName: Story = {
|
||||
args: {
|
||||
organizationNameByID: new Map<string, string>([
|
||||
[
|
||||
organizationWithEmptyDisplayName.id,
|
||||
organizationWithEmptyDisplayName.display_name ||
|
||||
organizationWithEmptyDisplayName.name,
|
||||
],
|
||||
]),
|
||||
organizations: [organizationWithEmptyDisplayName],
|
||||
thresholds: [{ model_config_id: "model-1", threshold_percent: 90 }],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
canvas.getAllByText(organizationWithEmptyDisplayName.name).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
canvas.getByRole("textbox", {
|
||||
name: `GPT-4o compaction threshold for ${organizationWithEmptyDisplayName.name}`,
|
||||
@@ -320,6 +318,131 @@ export const PartialSaveFailure: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const OrganizationFilter: Story = {
|
||||
args: {
|
||||
models: [
|
||||
mockModels[0],
|
||||
{
|
||||
...mockModels[1],
|
||||
organization_id: MockOrganization2.id,
|
||||
},
|
||||
],
|
||||
organizations: [modelsOrganization, MockOrganization2],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const filter = await canvas.findByRole("button", {
|
||||
name: `Organization ${modelsOrganization.display_name}`,
|
||||
});
|
||||
|
||||
expect(canvas.getByText("GPT-4o")).toBeInTheDocument();
|
||||
expect(canvas.queryByText("Claude Sonnet")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(filter);
|
||||
const option = await within(document.body).findByRole("option", {
|
||||
name: MockOrganization2.display_name,
|
||||
});
|
||||
await userEvent.click(option);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(canvas.queryByText("GPT-4o")).not.toBeInTheDocument();
|
||||
expect(canvas.getByText("Claude Sonnet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
canvas.getByRole("button", {
|
||||
name: `Organization ${MockOrganization2.display_name}`,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleOrganizationHidesFilter: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByText("GPT-4o");
|
||||
expect(
|
||||
canvas.queryByRole("button", { name: /^Organization / }),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const OrganizationFilterScopesSaveActions: Story = {
|
||||
args: {
|
||||
models: [
|
||||
mockModels[0],
|
||||
{
|
||||
...mockModels[1],
|
||||
organization_id: MockOrganization2.id,
|
||||
},
|
||||
],
|
||||
organizations: [modelsOrganization, MockOrganization2],
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const gpt4oInput = await canvas.findByRole("textbox", {
|
||||
name: /GPT-4o compaction threshold/i,
|
||||
});
|
||||
await userEvent.type(gpt4oInput, "95");
|
||||
await canvas.findByRole("button", { name: /Save 1 change/i });
|
||||
|
||||
// Switch to the other organization: the draft belongs to a hidden
|
||||
// row, so the footer must disappear.
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", {
|
||||
name: `Organization ${modelsOrganization.display_name}`,
|
||||
}),
|
||||
);
|
||||
await userEvent.click(
|
||||
await within(document.body).findByRole("option", {
|
||||
name: MockOrganization2.display_name,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(canvas.queryByRole("button", { name: /Save/i })).toBeNull();
|
||||
});
|
||||
|
||||
// Editing the visible row saves only that row.
|
||||
const claudeInput = await canvas.findByRole("textbox", {
|
||||
name: /Claude Sonnet compaction threshold/i,
|
||||
});
|
||||
await userEvent.type(claudeInput, "50");
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("button", { name: /Save 1 change/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveThreshold).toHaveBeenCalledWith("model-2", 50);
|
||||
expect(args.onSaveThreshold).not.toHaveBeenCalledWith("model-1", 95);
|
||||
});
|
||||
|
||||
// Switching back restores the hidden draft and its footer.
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", {
|
||||
name: `Organization ${MockOrganization2.display_name}`,
|
||||
}),
|
||||
);
|
||||
await userEvent.click(
|
||||
await within(document.body).findByRole("option", {
|
||||
name: modelsOrganization.display_name,
|
||||
}),
|
||||
);
|
||||
const restoredInput = await canvas.findByRole("textbox", {
|
||||
name: /GPT-4o compaction threshold/i,
|
||||
});
|
||||
expect(restoredInput).toHaveValue("95");
|
||||
// Wait out the temporary "Saved" footer state (2.5s) before the
|
||||
// action buttons reappear.
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(
|
||||
canvas.getByRole("button", { name: /Save 1 change/i }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorState: Story = {
|
||||
name: "Error",
|
||||
args: {
|
||||
@@ -338,9 +461,6 @@ export const PartialModelLoadError: Story = {
|
||||
expect(
|
||||
await canvas.findByText("Failed to load models from one organization"),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
canvas.getAllByText(MockDefaultOrganization.display_name).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
canvas.getByRole("textbox", {
|
||||
name: `GPT-4o compaction threshold for ${MockDefaultOrganization.display_name}`,
|
||||
|
||||
@@ -5,6 +5,10 @@ import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { Input } from "#/components/Input/Input";
|
||||
import {
|
||||
getOrganizationLabel,
|
||||
OrganizationAutocomplete,
|
||||
} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import {
|
||||
Table,
|
||||
@@ -31,7 +35,7 @@ import { ProviderIcon } from "./ChatModelAdminPanel/ProviderIcon";
|
||||
interface UserCompactionThresholdSettingsProps {
|
||||
models: readonly TypesGen.ChatModel[];
|
||||
providerTypeByID: ReadonlyMap<string, string>;
|
||||
organizationNameByID: ReadonlyMap<string, string>;
|
||||
organizations: readonly TypesGen.Organization[];
|
||||
modelsError?: unknown;
|
||||
isLoadingModels?: boolean;
|
||||
thresholds: readonly TypesGen.UserChatCompactionThreshold[] | undefined;
|
||||
@@ -75,7 +79,7 @@ export const UserCompactionThresholdSettings: FC<
|
||||
> = ({
|
||||
models,
|
||||
providerTypeByID,
|
||||
organizationNameByID,
|
||||
organizations,
|
||||
modelsError,
|
||||
isLoadingModels,
|
||||
thresholds,
|
||||
@@ -87,9 +91,32 @@ export const UserCompactionThresholdSettings: FC<
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [rowErrors, setRowErrors] = useState<Record<string, string>>({});
|
||||
const [pendingModels, setPendingModels] = useState<Set<string>>(new Set());
|
||||
const [selectedOrganizationID, setSelectedOrganizationID] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const { isSavedVisible, showSavedState } = useTemporarySavedState();
|
||||
|
||||
const enabledModels = models.filter((config) => config.enabled);
|
||||
const organizationNameByID = new Map(
|
||||
organizations.map((organization) => [
|
||||
organization.id,
|
||||
organization.display_name || organization.name,
|
||||
]),
|
||||
);
|
||||
const organizationOptions = organizations.filter((organization) =>
|
||||
enabledModels.some((config) => config.organization_id === organization.id),
|
||||
);
|
||||
const activeOrganization =
|
||||
organizationOptions.find(
|
||||
(organization) => organization.id === selectedOrganizationID,
|
||||
) ??
|
||||
organizationOptions.find((organization) => organization.is_default) ??
|
||||
organizationOptions[0];
|
||||
const visibleModels = activeOrganization
|
||||
? enabledModels.filter(
|
||||
(config) => config.organization_id === activeOrganization.id,
|
||||
)
|
||||
: enabledModels;
|
||||
const overridesByModelID = new Map(
|
||||
(thresholds ?? []).map(
|
||||
(threshold: TypesGen.UserChatCompactionThreshold) => [
|
||||
@@ -152,10 +179,11 @@ export const UserCompactionThresholdSettings: FC<
|
||||
});
|
||||
};
|
||||
|
||||
// Compute dirty rows: rows where the user has typed a valid value
|
||||
// that differs from the current server-side override.
|
||||
// Save/cancel act only on visible rows; drafts hidden by the org
|
||||
// picker are kept untouched.
|
||||
const visibleModelIDs = new Set(visibleModels.map((config) => config.id));
|
||||
const dirtyRows: Array<{ modelId: string; value: number }> = [];
|
||||
for (const modelConfig of enabledModels) {
|
||||
for (const modelConfig of visibleModels) {
|
||||
const draft = drafts[modelConfig.id];
|
||||
if (draft === undefined) continue;
|
||||
const parsed = parseThresholdDraft(draft);
|
||||
@@ -197,13 +225,31 @@ export const UserCompactionThresholdSettings: FC<
|
||||
};
|
||||
|
||||
const handleCancelAll = () => {
|
||||
setDrafts({});
|
||||
setRowErrors({});
|
||||
setDrafts((currentDrafts) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(currentDrafts).filter(
|
||||
([modelID]) => !visibleModelIDs.has(modelID),
|
||||
),
|
||||
),
|
||||
);
|
||||
setRowErrors((currentErrors) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(currentErrors).filter(
|
||||
([modelID]) => !visibleModelIDs.has(modelID),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const hasAnyPending = pendingModels.size > 0;
|
||||
const hasAnyErrors = Object.keys(rowErrors).length > 0;
|
||||
const hasAnyDrafts = Object.keys(drafts).length > 0;
|
||||
const hasAnyPending = [...pendingModels].some((modelID) =>
|
||||
visibleModelIDs.has(modelID),
|
||||
);
|
||||
const hasAnyErrors = Object.keys(rowErrors).some((modelID) =>
|
||||
visibleModelIDs.has(modelID),
|
||||
);
|
||||
const hasAnyDrafts = Object.keys(drafts).some((modelID) =>
|
||||
visibleModelIDs.has(modelID),
|
||||
);
|
||||
const shouldShowActions =
|
||||
hasAnyDrafts || hasAnyErrors || hasAnyPending || dirtyRows.length > 0;
|
||||
|
||||
@@ -260,6 +306,26 @@ export const UserCompactionThresholdSettings: FC<
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{organizationOptions.length > 1 && activeOrganization && (
|
||||
<div>
|
||||
<OrganizationAutocomplete
|
||||
value={activeOrganization}
|
||||
ariaLabel={`Organization ${getOrganizationLabel(
|
||||
activeOrganization,
|
||||
organizationOptions,
|
||||
)}`}
|
||||
options={organizationOptions}
|
||||
triggerClassName="w-60"
|
||||
optionsTabbable
|
||||
onChange={(organization) => {
|
||||
if (!organization) {
|
||||
return;
|
||||
}
|
||||
setSelectedOrganizationID(organization.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -271,7 +337,7 @@ export const UserCompactionThresholdSettings: FC<
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{enabledModels.map((modelConfig) => {
|
||||
{visibleModels.map((modelConfig) => {
|
||||
const existingOverride = overridesByModelID.get(modelConfig.id);
|
||||
const hasOverride = overridesByModelID.has(modelConfig.id);
|
||||
const draftValue =
|
||||
@@ -300,7 +366,7 @@ export const UserCompactionThresholdSettings: FC<
|
||||
<TableRow key={modelConfig.id}>
|
||||
<TableCell className="text-sm font-medium text-content-primary">
|
||||
<Badge
|
||||
size="sm"
|
||||
size="md"
|
||||
variant="default"
|
||||
className="w-fit"
|
||||
aria-label={`${providerLabel} ${modelName} in ${organizationName}`}
|
||||
@@ -308,11 +374,6 @@ export const UserCompactionThresholdSettings: FC<
|
||||
<ProviderIcon provider={provider} className="size-4" />
|
||||
{modelName}
|
||||
</Badge>
|
||||
{organizationName && (
|
||||
<span className="mt-0.5 block text-2xs font-normal text-content-secondary">
|
||||
{organizationName}
|
||||
</span>
|
||||
)}
|
||||
{rowError && (
|
||||
<p
|
||||
aria-live="polite"
|
||||
@@ -438,12 +499,11 @@ export const UserCompactionThresholdSettings: FC<
|
||||
<Button
|
||||
size="xs"
|
||||
type="button"
|
||||
className="h-6"
|
||||
disabled={hasAnyPending}
|
||||
onClick={handleSaveAll}
|
||||
>
|
||||
{hasAnyPending && (
|
||||
<Spinner loading className="size-4" />
|
||||
)}
|
||||
{hasAnyPending && <Spinner loading size="sm" />}
|
||||
{hasAnyPending
|
||||
? "Saving..."
|
||||
: `Save ${dirtyRows.length} ${dirtyRows.length === 1 ? "change" : "changes"}`}
|
||||
|
||||
Reference in New Issue
Block a user