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:
TJ
2026-08-26 10:50:24 -07:00
committed by GitHub
parent 35cbca080e
commit 2eee703ec4
5 changed files with 222 additions and 51 deletions
@@ -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"}`}