diff --git a/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx b/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx index 9654154dea7..16a338e6b1e 100644 --- a/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx @@ -43,14 +43,7 @@ const AgentSettingsCompactionPage: FC = () => { [ - organization.id, - organization.display_name || organization.name, - ]), - ) - } + organizations={organizations} modelsError={organizationModels.error ?? organizationModels.partialError} isLoadingModels={organizationModels.isLoading} thresholds={thresholdsQuery.data?.thresholds} diff --git a/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.stories.tsx index bdf4e0755d0..5c9ecdd97a4 100644 --- a/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.stories.tsx @@ -21,9 +21,7 @@ const baseArgs: AgentSettingsCompactionPageViewProps = { }, ], providerTypeByID: new Map([["prov-openai", "openai"]]), - organizationNameByID: new Map([ - [MockDefaultOrganization.id, MockDefaultOrganization.display_name], - ]), + organizations: [MockDefaultOrganization], modelsError: undefined, isLoadingModels: false, thresholds: [ diff --git a/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.tsx index 75ae171be02..61136d266f2 100644 --- a/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.tsx @@ -6,7 +6,7 @@ import { UserCompactionThresholdSettings } from "./components/UserCompactionThre export interface AgentSettingsCompactionPageViewProps { models: readonly TypesGen.ChatModel[] | undefined; providerTypeByID: ReadonlyMap; - organizationNameByID: ReadonlyMap; + 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< ([ - [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([ - [ - 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}`, diff --git a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx index 4a32d00fe95..a2c9fc5524c 100644 --- a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx +++ b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx @@ -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; - organizationNameByID: ReadonlyMap; + 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>({}); const [rowErrors, setRowErrors] = useState>({}); const [pendingModels, setPendingModels] = useState>(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< )}

)} + {organizationOptions.length > 1 && activeOrganization && ( +
+ { + if (!organization) { + return; + } + setSelectedOrganizationID(organization.id); + }} + /> +
+ )} @@ -271,7 +337,7 @@ export const UserCompactionThresholdSettings: FC< - {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< {modelName} - {organizationName && ( - - {organizationName} - - )} {rowError && (

- {hasAnyPending && ( - - )} + {hasAnyPending && } {hasAnyPending ? "Saving..." : `Save ${dirtyRows.length} ${dirtyRows.length === 1 ? "change" : "changes"}`}