diff --git a/docs/ai-coder/agents/models.md b/docs/ai-coder/agents/models.md index 1f59c29ad3b..a452c4df53f 100644 --- a/docs/ai-coder/agents/models.md +++ b/docs/ai-coder/agents/models.md @@ -206,11 +206,11 @@ To change the default model: The Models list reflects whether each model can actually be used: - When a model's connected provider has been deleted, the **Provider** column - shows **Unset** with an info tooltip that reads "The provider connected to - this model has been deleted." -- When a model's provider is missing or disabled, the **Status** column - shows **Disabled**, regardless of the model's own enabled setting. Such a - model cannot serve chat requests. + shows **Unset**. +- When a model's provider is missing or disabled, an **Unavailable** badge + appears beside the model name. The badge's tooltip explains whether the + provider was deleted or disabled. Such a model cannot serve chat requests. +- When a model is disabled, a **Disabled** badge appears beside the model name. To reconnect a model to a working provider, open the model from the list, pick a new provider from the **Provider** dropdown, and click **Save**. The @@ -376,7 +376,7 @@ from the Agents settings page. 1. Each enabled provider is listed with a status indicator: - **Key saved**, your personal key is active and will be used for requests to that provider. - - **Using shared key**, no personal key is set and Coder is using + - **Shared key**, no personal key is set and Coder is using deployment-managed credentials for that provider. - **No key**, no personal key or deployment-managed credential is available. Add a personal key before you use models from this provider. diff --git a/site/src/components/Combobox/Combobox.tsx b/site/src/components/Combobox/Combobox.tsx index d08e56ac19c..347c4322e34 100644 --- a/site/src/components/Combobox/Combobox.tsx +++ b/site/src/components/Combobox/Combobox.tsx @@ -114,21 +114,23 @@ export const ComboboxContent = ({ - - {children} - + {children} ); }; export const ComboboxInput = CommandInput; -export const ComboboxList = CommandList; +export const ComboboxList: React.FC< + React.ComponentPropsWithRef +> = ({ className, ...props }) => { + return ; +}; export const ComboboxItem = ({ children, @@ -143,7 +145,7 @@ export const ComboboxItem = ({ return ( { setOpen(false); // Toggle behavior: selecting the same value deselects it. diff --git a/site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.stories.tsx b/site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.stories.tsx deleted file mode 100644 index 52b8a38f1d2..00000000000 --- a/site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.stories.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { - expect, - fireEvent, - fn, - userEvent, - waitFor, - within, -} from "storybook/test"; -import { formatDateTime } from "#/utils/time"; -import { DateTimeRangeFilter } from "./DateTimeRangeFilter"; -import type { TimeRange } from "./timeRange"; - -const fixedNow = new Date(2026, 7, 13, 15, 0, 0); - -const defaultValue: TimeRange = { - startedAfter: new Date(2026, 7, 12, 15, 0, 0), - startedBefore: fixedNow, -}; - -const singleDayValue: TimeRange = { - startedAfter: new Date(2026, 3, 10, 7, 23, 0), - startedBefore: new Date(2026, 3, 10, 9, 30, 0), -}; - -const meta: Meta = { - title: "components/DateTimeRangeFilter", - component: DateTimeRangeFilter, - args: { - now: fixedNow, - value: defaultValue, - defaultValue, - onChange: fn(), - }, -}; - -export default meta; -type Story = StoryObj; - -export const DefaultLabel: Story = { - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - expect( - canvas.getByRole("button", { name: "Filter by time range" }), - ).toHaveTextContent("Last 24 hours"); - expect(args.onChange).not.toHaveBeenCalled(); - }, -}; - -export const SingleDayLabel: Story = { - args: { - value: singleDayValue, - }, -}; - -export const RangeEndingTodayLabel: Story = { - args: { - value: { - startedAfter: new Date(2026, 7, 11, 23, 59, 59), - startedBefore: new Date(2026, 7, 13, 10, 0, 0), - }, - }, -}; - -export const SameMonthLabel: Story = { - args: { - value: { - startedAfter: new Date(2026, 3, 17), - startedBefore: new Date(2026, 3, 19), - }, - }, -}; - -export const OpenPrefillsExpressions: Story = { - args: { - value: singleDayValue, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const body = within(canvasElement.ownerDocument.body); - await userEvent.click( - canvas.getByRole("button", { name: "Filter by time range" }), - ); - - // Committed bounds are shown as absolute local expressions. - const fromInput = await body.findByLabelText("Start of time range"); - const toInput = body.getByLabelText("End of time range"); - expect(fromInput).toHaveValue("2026-04-10 07:23:00"); - expect(toInput).toHaveValue("2026-04-10 09:30:00"); - - // The examples footer explains the accepted grammar. - expect(body.getByText("Examples:")).toBeInTheDocument(); - expect( - body.getByText("Defaults to midnight if no time is provided."), - ).toBeInTheDocument(); - - // Apply stays disabled until the selection changes. - expect(body.getByRole("button", { name: "Apply" })).toBeDisabled(); - }, -}; - -export const OpenPrefillsNowForCurrentBoundary: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const body = within(canvasElement.ownerDocument.body); - await userEvent.click( - canvas.getByRole("button", { name: "Filter by time range" }), - ); - - // The default end boundary is the current moment, so it reads as - // "now"; the start boundary is a frozen timestamp. - const fromInput = await body.findByLabelText("Start of time range"); - const toInput = body.getByLabelText("End of time range"); - expect(fromInput).toHaveValue(formatDateTime(defaultValue.startedAfter)); - expect(toInput).toHaveValue("now"); - }, -}; - -export const BlurNormalizesUnderspecifiedInput: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const body = within(canvasElement.ownerDocument.body); - await userEvent.click( - canvas.getByRole("button", { name: "Filter by time range" }), - ); - - // A clock-only expression resolves to the current day once the - // input loses focus, so the user sees what will be applied. - const fromInput = await body.findByLabelText("Start of time range"); - await userEvent.click(fromInput); - await fireEvent.change(fromInput, { target: { value: "12:34" } }); - await userEvent.tab(); - await waitFor(() => { - expect(fromInput).toHaveValue("2026-08-13 12:34:00"); - }); - - // "now" is already unambiguous and stays untouched. - const toInput = body.getByLabelText("End of time range"); - await userEvent.tab(); - expect(toInput).toHaveValue("now"); - }, -}; - -export const BlurClampsReversedRange: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const body = within(canvasElement.ownerDocument.body); - await userEvent.click( - canvas.getByRole("button", { name: "Filter by time range" }), - ); - - // Blurring an out-of-order boundary snaps it back inside the range. - const fromInput = await body.findByLabelText("Start of time range"); - const toInput = body.getByLabelText("End of time range"); - await userEvent.click(fromInput); - await fireEvent.change(fromInput, { - target: { value: "2026-08-13 16:00" }, - }); - await fireEvent.change(toInput, { target: { value: "2026-08-13 08:00" } }); - // Tabbing out of From clamps it below To; tabbing out of To then - // reformats it without changing the now-valid range. - await userEvent.tab(); - await userEvent.tab(); - await waitFor(() => { - expect(fromInput).toHaveValue("2026-08-13 07:59:59"); - }); - expect(toInput).toHaveValue("2026-08-13 08:00:00"); - expect(body.queryByText("From must be before To")).toBeNull(); - }, -}; - -export const ApplyCommitsSelection: Story = { - args: { - onChange: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - const body = within(canvasElement.ownerDocument.body); - await userEvent.click( - canvas.getByRole("button", { name: "Filter by time range" }), - ); - - const fromInput = await body.findByLabelText("Start of time range"); - await fireEvent.change(fromInput, { - target: { value: "2026-08-13 08:00" }, - }); - - const applyButton = body.getByRole("button", { name: "Apply" }); - expect(applyButton).toBeEnabled(); - await userEvent.click(applyButton); - - await waitFor(() => { - expect(body.queryByRole("button", { name: "Apply" })).toBeNull(); - }); - expect(args.onChange).toHaveBeenCalledWith({ - startedAfter: new Date(2026, 7, 13, 8, 0, 0), - startedBefore: fixedNow, - }); - }, -}; - -export const InvalidExpressionShowsError: Story = { - args: { - onChange: fn(), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const body = within(canvasElement.ownerDocument.body); - await userEvent.click( - canvas.getByRole("button", { name: "Filter by time range" }), - ); - - const fromInput = await body.findByLabelText("Start of time range"); - await fireEvent.change(fromInput, { target: { value: "30d" } }); - expect(fromInput).toHaveAttribute("aria-invalid", "true"); - expect( - body.getByText("Enter a valid time, e.g. 2026-08-13 11:43"), - ).toBeInTheDocument(); - expect(body.getByRole("button", { name: "Apply" })).toBeDisabled(); - }, -}; - -export const ReversedRangeShowsError: Story = { - args: { - onChange: fn(), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const body = within(canvasElement.ownerDocument.body); - await userEvent.click( - canvas.getByRole("button", { name: "Filter by time range" }), - ); - - // From after To is not a valid range and gets its own message. - const fromInput = await body.findByLabelText("Start of time range"); - const toInput = body.getByLabelText("End of time range"); - await fireEvent.change(fromInput, { - target: { value: "2026-08-13 16:00" }, - }); - await fireEvent.change(toInput, { target: { value: "2026-08-13 08:00" } }); - expect(body.getByText("From must be before To")).toBeInTheDocument(); - expect(body.getByRole("button", { name: "Apply" })).toBeDisabled(); - }, -}; - -export const EscapeClosesWithoutApplying: Story = { - args: { - onChange: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - const body = within(canvasElement.ownerDocument.body); - await userEvent.click( - canvas.getByRole("button", { name: "Filter by time range" }), - ); - - const fromInput = await body.findByLabelText("Start of time range"); - await fireEvent.change(fromInput, { - target: { value: "2026-08-13 08:00" }, - }); - await userEvent.keyboard("{Escape}"); - - await waitFor(() => { - expect(body.queryByRole("button", { name: "Apply" })).toBeNull(); - }); - expect(args.onChange).not.toHaveBeenCalled(); - }, -}; diff --git a/site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.tsx b/site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.tsx deleted file mode 100644 index d37159bf95e..00000000000 --- a/site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { CalendarIcon } from "lucide-react"; -import { type FC, useEffectEvent, useState } from "react"; -import { Button } from "#/components/Button/Button"; -import { Input } from "#/components/Input/Input"; -import { Label } from "#/components/Label/Label"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "#/components/Popover/Popover"; -import { cn } from "#/utils/cn"; -import { formatDateTime } from "#/utils/time"; -import { - formatTriggerLabel, - isNowExpression, - parseTimeExpression, - type TimeRange, -} from "./timeRange"; - -interface DateTimeRangeFilterProps { - value: TimeRange; - /** - * The range the page falls back to when no explicit filter is set. - * The trigger labels it as "Last 24 hours" while the value equals it. - */ - defaultValue: TimeRange; - onChange: (value: TimeRange) => void; - now?: Date; - /** Matches the SelectFilter trigger metrics in the filter row. */ - width?: number; -} - -interface FieldState { - text: string; - touched: boolean; -} - -const EXAMPLES = ["Now", "15:43", "2026-08-13 11:43"]; - -const INVALID_TIME_MESSAGE = "Enter a valid time, e.g. 2026-08-13 11:43"; - -const NOW_TOLERANCE_MS = 60 * 1000; - -export const DateTimeRangeFilter: FC = ({ - value, - defaultValue, - onChange, - now, - width = 200, -}) => { - const [open, setOpen] = useState(false); - const currentTime = now ?? new Date(); - const isDefault = - value.startedAfter.getTime() === defaultValue.startedAfter.getTime() && - value.startedBefore.getTime() === defaultValue.startedBefore.getTime(); - - // Text state is kept separate from the committed value so the user can - // adjust the expressions freely before applying; invalid text never - // leaks out. If this control grows more fields or validation rules, - // consider moving to formik and yup instead of hand-rolling state. - const [fromField, setFromField] = useState({ - text: "", - touched: false, - }); - const [toField, setToField] = useState({ - text: "", - touched: false, - }); - - const handleOpenChange = useEffectEvent((next: boolean) => { - if (next) { - // Boundaries at (or very near) the current moment read better - // as "now" than as a frozen timestamp when the popover reopens. - const toFieldText = (date: Date): string => - Math.abs(date.getTime() - currentTime.getTime()) < NOW_TOLERANCE_MS - ? "now" - : formatDateTime(date); - setFromField({ - text: toFieldText(value.startedAfter), - touched: false, - }); - setToField({ - text: toFieldText(value.startedBefore), - touched: false, - }); - } - setOpen(next); - }); - - const parsedFrom = parseTimeExpression(fromField.text, currentTime); - const parsedTo = parseTimeExpression(toField.text, currentTime); - - // Underspecified expressions resolve to absolute local timestamps when - // the input loses focus, so the user sees exactly what will be applied. - // "now" is left as-is because it is already unambiguous. Out-of-range - // values clamp against the other boundary so the committed range is - // always valid. - const normalizeFrom = useEffectEvent(() => { - setFromField((current) => { - if (isNowExpression(current.text) || parsedFrom === null) { - return current; - } - const clamped = - parsedTo !== null && parsedFrom.getTime() >= parsedTo.getTime() - ? new Date(parsedTo.getTime() - 1000) - : parsedFrom; - return { ...current, text: formatDateTime(clamped) }; - }); - }); - - const normalizeTo = useEffectEvent(() => { - setToField((current) => { - if (isNowExpression(current.text) || parsedTo === null) { - return current; - } - const clamped = - parsedFrom !== null && parsedTo.getTime() <= parsedFrom.getTime() - ? new Date(parsedFrom.getTime() + 1000) - : parsedTo; - return { ...current, text: formatDateTime(clamped) }; - }); - }); - - const fromError = - fromField.text !== "" && parsedFrom === null ? INVALID_TIME_MESSAGE : null; - const toError = - toField.text !== "" && parsedTo === null ? INVALID_TIME_MESSAGE : null; - const rangeError = - fromError === null && - toError === null && - parsedFrom !== null && - parsedTo !== null && - parsedFrom.getTime() >= parsedTo.getTime() - ? "From must be before To" - : null; - - // Apply is only useful when something actually changed; untouched - // fields resolve back to the committed range. - const applyDisabled = - !(fromField.touched || toField.touched) || - fromError !== null || - toError !== null || - rangeError !== null; - - const triggerLabel = isDefault - ? "Last 24 hours" - : formatTriggerLabel(value, currentTime); - - return ( - - - - - -
-
- - { - setFromField({ text: event.target.value, touched: true }); - }} - onBlur={normalizeFrom} - /> - {fromError !== null && ( - - {fromError} - - )} -
-
- - { - setToField({ text: event.target.value, touched: true }); - }} - onBlur={normalizeTo} - /> - {toError !== null && ( - - {toError} - - )} -
-
- {rangeError !== null && ( - - {rangeError} - - )} - -
-
-
- Examples: - {EXAMPLES.join(" | ")} - Defaults to midnight if no time is provided. - Defaults to current day if no date is provided. -
-
-
- ); -}; diff --git a/site/src/components/DateTimeRangeFilter/timeRange.test.ts b/site/src/components/DateTimeRangeFilter/timeRange.test.ts deleted file mode 100644 index 61637359844..00000000000 --- a/site/src/components/DateTimeRangeFilter/timeRange.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - formatTriggerLabel, - isNowExpression, - parseTimeExpression, -} from "./timeRange"; - -const now = new Date(2026, 7, 13, 15, 0, 0); - -describe("isNowExpression", () => { - it("matches now case-insensitively with surrounding whitespace", () => { - expect(isNowExpression("now")).toBe(true); - expect(isNowExpression("Now")).toBe(true); - expect(isNowExpression(" NOW ")).toBe(true); - }); - - it("rejects other input", () => { - expect(isNowExpression("now ")).toBe(true); - expect(isNowExpression("not-now")).toBe(false); - expect(isNowExpression("")).toBe(false); - expect(isNowExpression("2026-08-13")).toBe(false); - }); -}); - -describe("parseTimeExpression", () => { - it("parses now case-insensitively", () => { - expect(parseTimeExpression("now", now)).toEqual(now); - expect(parseTimeExpression("Now", now)).toEqual(now); - expect(parseTimeExpression(" NOW ", now)).toEqual(now); - }); - - it("parses clock times against the current day", () => { - expect(parseTimeExpression("15:43", now)).toEqual( - new Date(2026, 7, 13, 15, 43, 0), - ); - expect(parseTimeExpression("09:05:09", now)).toEqual( - new Date(2026, 7, 13, 9, 5, 9), - ); - }); - - it("defaults a bare date to midnight", () => { - expect(parseTimeExpression("2026-08-13", now)).toEqual( - new Date(2026, 7, 13), - ); - }); - - it("parses date and time together", () => { - expect(parseTimeExpression("2026-08-13 11:43", now)).toEqual( - new Date(2026, 7, 13, 11, 43, 0), - ); - expect(parseTimeExpression("2026-08-13 11:43:21", now)).toEqual( - new Date(2026, 7, 13, 11, 43, 21), - ); - }); - - it("rejects single-digit hours", () => { - expect(parseTimeExpression("9:05", now)).toBeNull(); - expect(parseTimeExpression("2026-08-13 7:23:00", now)).toBeNull(); - }); - - it("rejects out-of-range clocks and dates", () => { - expect(parseTimeExpression("23:59:99", now)).toBeNull(); - expect(parseTimeExpression("24:00", now)).toBeNull(); - expect(parseTimeExpression("2026-02-30", now)).toBeNull(); - expect(parseTimeExpression("2026-13-01", now)).toBeNull(); - expect(parseTimeExpression("2026-08-13 23:59:99", now)).toBeNull(); - }); - - it("rejects unknown shapes", () => { - expect(parseTimeExpression("", now)).toBeNull(); - expect(parseTimeExpression("30d", now)).toBeNull(); - expect(parseTimeExpression("13/08/2026", now)).toBeNull(); - expect(parseTimeExpression("2026-08-13T11:43", now)).toBeNull(); - }); -}); - -describe("formatTriggerLabel", () => { - it("collapses a single day", () => { - expect( - formatTriggerLabel( - { - startedAfter: new Date(2026, 3, 10, 7, 23, 0), - startedBefore: new Date(2026, 3, 10, 9, 30, 0), - }, - now, - ), - ).toBe("Apr 10"); - }); - - it("labels a range ending today against now", () => { - expect( - formatTriggerLabel( - { - startedAfter: new Date(2026, 7, 11, 23, 59, 59), - startedBefore: new Date(2026, 7, 13, 10, 0, 0), - }, - now, - ), - ).toBe("Aug 11 - Today"); - }); - - it("shortens ranges within one month", () => { - expect( - formatTriggerLabel( - { - startedAfter: new Date(2026, 3, 17), - startedBefore: new Date(2026, 3, 19), - }, - now, - ), - ).toBe("Apr 17 - 19"); - }); - - it("falls back to a full range across months", () => { - expect( - formatTriggerLabel( - { - startedAfter: new Date(2026, 2, 30), - startedBefore: new Date(2026, 3, 2), - }, - now, - ), - ).toBe("Mar 30 - Apr 2"); - }); -}); diff --git a/site/src/components/DateTimeRangeFilter/timeRange.ts b/site/src/components/DateTimeRangeFilter/timeRange.ts deleted file mode 100644 index 7185e18360d..00000000000 --- a/site/src/components/DateTimeRangeFilter/timeRange.ts +++ /dev/null @@ -1,93 +0,0 @@ -import dayjs from "dayjs"; -import customParseFormat from "dayjs/plugin/customParseFormat"; -import { DATE_FORMAT } from "#/utils/time"; - -dayjs.extend(customParseFormat); - -export type TimeRange = { - startedAfter: Date; - startedBefore: Date; -}; - -// dayjs strict parsing is width-exact, so the format tokens are -// zero-padded only (e.g. "09:45" parses, "9:45" does not). -const DATE_FORMATS = [ - DATE_FORMAT.ISO_DATE, - DATE_FORMAT.ISO_DATETIME, - DATE_FORMAT.ISO_DATETIME_MINUTE, -]; -const TIME_FORMATS = [DATE_FORMAT.TIME_24H, DATE_FORMAT.TIME_24H_MINUTE]; - -const NOW_PATTERN = /^now$/i; - -/** Whether an expression is the literal "now" (case-insensitive). */ -export const isNowExpression = (expression: string): boolean => - NOW_PATTERN.test(expression.trim()); - -/** - * Parses a human-friendly time expression in browser-local time: - * "now", a clock time (current day), a date (midnight), or a date - * with a clock time. Returns null for anything else. - */ -export const parseTimeExpression = ( - expression: string, - now: Date, -): Date | null => { - const trimmed = expression.trim(); - if (trimmed === "") { - return null; - } - if (isNowExpression(trimmed)) { - return new Date(now.getTime()); - } - - const dated = dayjs(trimmed, DATE_FORMATS, true); - if (dated.isValid()) { - return dated.toDate(); - } - - // Clock-only expressions resolve against the current day. - const clock = dayjs(trimmed, TIME_FORMATS, true); - if (clock.isValid()) { - return new Date( - now.getFullYear(), - now.getMonth(), - now.getDate(), - clock.hour(), - clock.minute(), - clock.second(), - ); - } - - return null; -}; - -const sameDay = (a: Date, b: Date): boolean => - a.getFullYear() === b.getFullYear() && - a.getMonth() === b.getMonth() && - a.getDate() === b.getDate(); - -const MONTH_DAY = "MMM D"; - -/** - * Summarizes a resolved range the way the filter trigger displays it: - * a single day, a range ending today, a range within one month, or a - * full from-to range. Callers render "Last 24 hours" for the default - * range before falling back to this. - */ -export const formatTriggerLabel = (range: TimeRange, now: Date): string => { - const from = dayjs(range.startedAfter); - if (sameDay(range.startedAfter, range.startedBefore)) { - return from.format(MONTH_DAY); - } - if (sameDay(range.startedBefore, now)) { - return `${from.format(MONTH_DAY)} - Today`; - } - if ( - range.startedAfter.getFullYear() === range.startedBefore.getFullYear() && - range.startedAfter.getMonth() === range.startedBefore.getMonth() - ) { - return `${from.format(MONTH_DAY)} - ${range.startedBefore.getDate()}`; - } - return `${from.format(MONTH_DAY)} - ${dayjs(range.startedBefore).format(MONTH_DAY)}`; -}; diff --git a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx index fe551b52892..f3c1d7e1207 100644 --- a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx +++ b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx @@ -211,10 +211,12 @@ export const DateTimeRangePicker: FC = ({ return ( - = ({ shouldFilter={false} > {selectFilterSearch} - + {options !== undefined ? ( options.map((option) => ( = ({ aria-required={required} data-testid="organization-autocomplete" className={cn( - "w-full justify-start gap-2 font-normal", + "group w-full justify-start gap-2 font-normal", triggerClassName, )} > @@ -154,3 +155,129 @@ export const OrganizationAutocomplete: FC = ({ ); }; + +type OrganizationValueProps = { + organization: Organization; + labelOrganizations?: readonly Organization[]; + id?: string; + className?: string; +}; + +const OrganizationValue: FC = ({ + organization, + labelOrganizations, + id, + className, +}) => { + const label = getOrganizationLabel( + organization, + labelOrganizations ?? [organization], + ); + return ( +
+ + {label} +
+ ); +}; + +type OrganizationFieldProps = { + id: string; + organization: Organization; + organizations: readonly Organization[]; + labelOrganizations?: readonly Organization[]; + onChange?: (organization: Organization) => void; + className?: string; + disabled?: boolean; + label?: string; + showLabel?: boolean; + showSingleOrganization?: boolean; + readOnly?: boolean; + triggerClassName?: string; + optionsTabbable?: boolean; + required?: boolean; +}; + +export const OrganizationField: FC = ({ + id, + organization, + organizations, + labelOrganizations, + onChange, + className, + disabled, + label = "Organization", + showLabel = true, + showSingleOrganization = false, + readOnly = false, + triggerClassName, + optionsTabbable, + required = true, +}) => { + const hasSingleSelectedOrganization = + organizations.length <= 1 && + organizations.some((option) => option.id === organization.id); + if (hasSingleSelectedOrganization && !showSingleOrganization && !readOnly) { + return null; + } + + const resolvedLabelOrganizations = + labelOrganizations ?? + (organizations.some((option) => option.id === organization.id) + ? organizations + : [...organizations, organization]); + const organizationLabel = getOrganizationLabel( + organization, + resolvedLabelOrganizations, + ); + const isReadOnly = readOnly || !onChange || hasSingleSelectedOrganization; + + return ( +
+ {showLabel && ( + + )} + {isReadOnly ? ( + + ) : ( + { + if (org) { + onChange?.(org); + } + }} + options={organizations} + labelOrganizations={resolvedLabelOrganizations} + required={required} + disabled={disabled} + triggerClassName={triggerClassName} + optionsTabbable={optionsTabbable} + /> + )} +
+ ); +}; diff --git a/site/src/components/SettingsHeader/SettingsHeader.tsx b/site/src/components/SettingsHeader/SettingsHeader.tsx index fcac611aa85..cd5015a9bc7 100644 --- a/site/src/components/SettingsHeader/SettingsHeader.tsx +++ b/site/src/components/SettingsHeader/SettingsHeader.tsx @@ -1,7 +1,6 @@ import { cva, type VariantProps } from "class-variance-authority"; -import { SquareArrowOutUpRightIcon } from "lucide-react"; import type { FC, PropsWithChildren, ReactNode } from "react"; -import { Button } from "#/components/Button/Button"; +import { Link } from "#/components/Link/Link"; import { cn } from "#/utils/cn"; type SettingsHeaderProps = Readonly< @@ -26,20 +25,22 @@ export const SettingsHeader: FC = ({ }; type SettingsHeaderDocsLinkProps = Readonly< - PropsWithChildren<{ href: string }> + PropsWithChildren<{ + href: string; + context?: string; + }> >; export const SettingsHeaderDocsLink: FC = ({ href, - children = "Read the docs", + context, + children = "View docs", }) => { return ( - + + {children} + {context && {context}} + (opens in new tab) + ); }; diff --git a/site/src/modules/provisioners/ProvisionerTags.tsx b/site/src/modules/provisioners/ProvisionerTags.tsx index 07fc70b9eb1..ac913654927 100644 --- a/site/src/modules/provisioners/ProvisionerTags.tsx +++ b/site/src/modules/provisioners/ProvisionerTags.tsx @@ -21,7 +21,7 @@ type ProvisionerTagProps = { export const ProvisionerTag: FC = ({ label, value }) => { return ( - + [{label} {value && `=${value}`}] @@ -46,7 +46,7 @@ export const ProvisionerTruncateTags: FC = ({ tags }) => { return ( - {remainderCount > 0 && +{remainderCount}} + {remainderCount > 0 && +{remainderCount}} ); }; diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.tsx index dffb8eb6d5d..e06d45cad0b 100644 --- a/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.tsx @@ -22,7 +22,6 @@ import type { SelectFilterOption } from "#/components/Filter/SelectFilter"; import { FormFields } from "#/components/Form/Form"; import { Loader } from "#/components/Loader/Loader"; import { TemplateUpdateMessage } from "#/modules/templates/TemplateUpdateMessage"; -import { cn } from "#/utils/cn"; import { createDayString } from "#/utils/createDayString"; type ChangeWorkspaceVersionDialogProps = { @@ -115,10 +114,7 @@ export const ChangeWorkspaceVersionDialog: FC< key={option.id} value={option.id} keywords={[option.name]} - className={cn( - "px-3 py-2 font-normal", - "data-[selected=true]:bg-surface-tertiary", - )} + className="px-3 py-2 font-normal" > = () => { return ( - - } - > +
AI Sessions @@ -24,7 +20,8 @@ const AIBridgeSessionsLayout: FC = () => { Review and audit AI activity, token usage, and prompt history across - sessions. + sessions.{" "} + View docs diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx index fcf6233816d..72ccf734211 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx @@ -73,8 +73,8 @@ const AISessionListPage: FC = () => { explicitTimeRange === null ? "last_24h" : lastPicked?.preset !== undefined && - sameSecond(lastPicked.start, timeRange.startedAfter) && - sameSecond(lastPicked.end, timeRange.startedBefore) + sameSecond(lastPicked.start, timeRange.start) && + sameSecond(lastPicked.end, timeRange.end) ? lastPicked.preset : undefined; @@ -138,18 +138,13 @@ const AISessionListPage: FC = () => { model: modelMenu, }, timeRange: { - start: timeRange.startedAfter, - end: timeRange.startedBefore, + start: timeRange.start, + end: timeRange.end, preset, }, onTimeRangeChange: (value) => { setLastPicked(value); - filter.update( - queryWithTimeRange(filter.values, { - startedAfter: value.start, - startedBefore: value.end, - }), - ); + filter.update(queryWithTimeRange(filter.values, value)); }, }} /> diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts index b2a00ea81ec..47102f0bf85 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts +++ b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { TimeRange } from "#/components/DateTimeRangeFilter/timeRange"; +import type { TimeRange } from "./timeRange"; import { defaultTimeRange, parseTimeRange, @@ -21,17 +21,15 @@ describe("toRFC3339", () => { describe("defaultTimeRange", () => { it("spans the 24 hours ending at now", () => { const range = defaultTimeRange(now); - expect(range.startedBefore).toEqual(now); - expect(range.startedAfter).toEqual( - new Date(now.getTime() - 24 * 60 * 60 * 1000), - ); + expect(range.end).toEqual(now); + expect(range.start).toEqual(new Date(now.getTime() - 24 * 60 * 60 * 1000)); }); }); describe("withDefaultTimeRange", () => { const range: TimeRange = { - startedAfter: new Date(Date.UTC(2026, 7, 12, 15, 0, 0)), - startedBefore: new Date(Date.UTC(2026, 7, 13, 15, 0, 0)), + start: new Date(Date.UTC(2026, 7, 12, 15, 0, 0)), + end: new Date(Date.UTC(2026, 7, 13, 15, 0, 0)), }; it("appends the default range to an empty query", () => { @@ -65,8 +63,8 @@ describe("withDefaultTimeRange", () => { describe("queryWithTimeRange", () => { const range: TimeRange = { - startedAfter: new Date(Date.UTC(2026, 7, 12, 15, 0, 0)), - startedBefore: new Date(Date.UTC(2026, 7, 13, 15, 0, 0)), + start: new Date(Date.UTC(2026, 7, 12, 15, 0, 0)), + end: new Date(Date.UTC(2026, 7, 13, 15, 0, 0)), }; it("preserves other filters and replaces the time range", () => { @@ -93,8 +91,8 @@ describe("parseTimeRange", () => { started_before: "2026-08-13T15:00:00Z", }), ).toEqual({ - startedAfter: new Date(Date.UTC(2026, 7, 12, 15, 0, 0)), - startedBefore: new Date(Date.UTC(2026, 7, 13, 15, 0, 0)), + start: new Date(Date.UTC(2026, 7, 12, 15, 0, 0)), + end: new Date(Date.UTC(2026, 7, 13, 15, 0, 0)), }); }); diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts index 1d33e59719a..6de07c7020e 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts +++ b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts @@ -1,6 +1,6 @@ import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; -import type { TimeRange } from "#/components/DateTimeRangeFilter/timeRange"; +import type { DateTimeRangeValue } from "#/components/DateTimeRangePicker/dateTimeRange"; import { parseFilterQuery, stringifyFilter, @@ -8,6 +8,9 @@ import { dayjs.extend(utc); +/** The resolved time window a sessions query spans. */ +export type TimeRange = Pick; + /** Serializes a Date as RFC 3339 in UTC with second precision. */ export const toRFC3339 = (date: Date): string => { return dayjs(date).utc().format("YYYY-MM-DDTHH:mm:ss[Z]"); @@ -15,8 +18,8 @@ export const toRFC3339 = (date: Date): string => { /** The default sessions window: the 24 hours ending at now. */ export const defaultTimeRange = (now: Date): TimeRange => ({ - startedAfter: new Date(now.getTime() - 24 * 60 * 60 * 1000), - startedBefore: now, + start: new Date(now.getTime() - 24 * 60 * 60 * 1000), + end: now, }); /** @@ -38,8 +41,8 @@ export const withDefaultTimeRange = ( } const suffix = stringifyFilter({ ...values, - started_after: toRFC3339(range.startedAfter), - started_before: toRFC3339(range.startedBefore), + started_after: toRFC3339(range.start), + started_before: toRFC3339(range.end), }); return suffix; }; @@ -56,8 +59,8 @@ export const queryWithTimeRange = ( ): string => { return stringifyFilter({ ...values, - started_after: toRFC3339(range.startedAfter), - started_before: toRFC3339(range.startedBefore), + started_after: toRFC3339(range.start), + started_before: toRFC3339(range.end), }); }; @@ -70,13 +73,10 @@ export const parseTimeRange = ( if (!after || !before) { return null; } - const startedAfter = new Date(after); - const startedBefore = new Date(before); - if ( - Number.isNaN(startedAfter.getTime()) || - Number.isNaN(startedBefore.getTime()) - ) { + const start = new Date(after); + const end = new Date(before); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { return null; } - return { startedAfter, startedBefore }; + return { start, end }; }; diff --git a/site/src/pages/AIBridgePage/NetworkCallBadges.tsx b/site/src/pages/AIBridgePage/NetworkCallBadges.tsx index 2da53ba97cd..7a05f919fe9 100644 --- a/site/src/pages/AIBridgePage/NetworkCallBadges.tsx +++ b/site/src/pages/AIBridgePage/NetworkCallBadges.tsx @@ -37,11 +37,10 @@ export const NetworkCallBadges: FC = ({ summary }) => { aria-label="More info" className="flex items-center whitespace-nowrap border-0 bg-transparent p-0 text-inherit" > - + {summary.total.toLocaleString("en-US")} diff --git a/site/src/pages/AIBridgePage/TokenBadges.stories.tsx b/site/src/pages/AIBridgePage/TokenBadges.stories.tsx index 109c310da27..044c97bc58c 100644 --- a/site/src/pages/AIBridgePage/TokenBadges.stories.tsx +++ b/site/src/pages/AIBridgePage/TokenBadges.stories.tsx @@ -40,19 +40,3 @@ export const WithMetadata: Story = { }, }, }; - -export const SizeXs: Story = { - args: { - size: "xs", - inputTokens: 1234, - outputTokens: 567, - }, -}; - -export const SizeMd: Story = { - args: { - size: "md", - inputTokens: 1234, - outputTokens: 567, - }, -}; diff --git a/site/src/pages/AIBridgePage/TokenBadges.tsx b/site/src/pages/AIBridgePage/TokenBadges.tsx index dd4b39ce562..50e30a8797b 100644 --- a/site/src/pages/AIBridgePage/TokenBadges.tsx +++ b/site/src/pages/AIBridgePage/TokenBadges.tsx @@ -11,14 +11,12 @@ import { JsonPrettyPrinter } from "./JsonPrettyPrinter"; import { roundTokenDisplay } from "./utils"; interface TokenBadgesProps { - size?: "xs" | "sm" | "md"; inputTokens: number; outputTokens: number; tokenUsageMetadata?: Record; } export const TokenBadges: FC = ({ - size = "sm", inputTokens, outputTokens, tokenUsageMetadata, @@ -28,17 +26,14 @@ export const TokenBadges: FC = ({ - - + + {roundTokenDisplay(inputTokens)} - - + + {roundTokenDisplay(outputTokens)} diff --git a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.stories.tsx b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.stories.tsx index cf2d6169770..6b94315e252 100644 --- a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.stories.tsx @@ -83,7 +83,7 @@ export const Paywall: Story = { const cta = canvas.getByRole("link", { name: "Start trial for free" }); await expect(cta).toHaveAttribute("href", "/deployment/premium"); await expect( - canvas.getByRole("link", { name: /Read the docs/ }), + canvas.getByRole("link", { name: /View docs/ }), ).toHaveAttribute( "href", docs("/ai-coder/ai-gateway/standalone#create-a-gateway-key"), diff --git a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx index 2b12c6d2c27..9d5f269d329 100644 --- a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx +++ b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx @@ -47,25 +47,21 @@ export const GatewayKeysPageView: FC = ({
- - {!showPaywall && ( - - )} -
+ !showPaywall && ( + + ) } > AI Gateway Keys Keys authenticate standalone AI Gateway replicas to this deployment. - The key value is shown only once when created. + The key value is shown only once when created.{" "} + diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx index 66b3d67726a..27a6acdde48 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx @@ -38,9 +38,9 @@ export const Default: Story = { const addButton = canvas.getByRole("button", { name: "Add server" }); await expect( - canvas.getByRole("button", { - name: `Organization ${MockDefaultOrganization.display_name}`, - }), + canvas.getByLabelText( + `Organization ${MockDefaultOrganization.display_name}`, + ), ).toBeVisible(); await expect(addButton).toBeDisabled(); await userEvent.type(canvas.getByLabelText(/display name/i), "GitHub"); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx index 8582d61fb0e..5775f97cbef 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx @@ -34,15 +34,6 @@ const AddMCPServerPageView: FC = ({ return ( <> Codestin Search App - {canCreate ? ( = ({ } isSaving={isSaving} canSelectUserOIDC={canSelectUserOIDC} + organizationPicker={ + + } onCreateServer={onCreateServer} onCancel={canViewServerList ? onCancel : undefined} /> ) : ( - - You cannot add servers to this organization - - Choose an organization where you have permission to add MCP servers. - - + <> + + + You cannot add servers to this organization + + Choose an organization where you have permission to add MCP + servers. + + + )} ); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index 409526712a5..32579b03ece 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -24,7 +24,7 @@ import { import AddMCPServerPage from "./AddMCPServerPage/AddMCPServerPage"; import MCPServersPage from "./MCPServersPage"; import { orgSearchParam } from "./organizationParam"; -import { MockCoderMCPServer } from "./testFixtures"; +import { MockCoderMCPServer, MockGitHubMCPServer } from "./testFixtures"; import UpdateMCPServerPage from "./UpdateMCPServerPage/UpdateMCPServerPage"; const MockOrganization2MCPServer: TypesGen.MCPServerConfig = { @@ -538,15 +538,55 @@ export const AddDeepLinkShowsSingleCreatableOrganization: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const organization = await canvas.findByRole("button", { - name: `Organization ${MockOrganization2.display_name}`, - }); + const organization = await canvas.findByLabelText( + `Organization ${MockOrganization2.display_name}`, + ); await expect(organization).toBeVisible(); - await expect(organization).toBeDisabled(); + expect( + canvas.queryByRole("button", { + name: `Organization ${MockOrganization2.display_name}`, + }), + ).not.toBeInTheDocument(); await expect(canvas.getByLabelText(/display name/i)).toBeVisible(); }, }; +export const ListSearchFiltersServers: Story = { + parameters: { + organizations: [MockDefaultOrganization, MockOrganization2], + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers" }, + routing: { path: "/ai/settings/mcp-servers" }, + }), + }, + beforeEach: () => { + spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([ + MockCoderMCPServer, + MockGitHubMCPServer, + ]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText("Coder")).toBeVisible(); + await expect(canvas.getByText("GitHub")).toBeVisible(); + + const search = canvas.getByRole("searchbox", { name: "Search servers" }); + await userEvent.type(search, "github"); + await expect(canvas.getByText("GitHub")).toBeVisible(); + expect(canvas.queryByText("Coder")).not.toBeInTheDocument(); + + await userEvent.clear(search); + await userEvent.type(search, "no-such-server"); + await expect( + canvas.getByText("No servers match your search"), + ).toBeVisible(); + + await userEvent.clear(search); + await expect(canvas.getByText("Coder")).toBeVisible(); + await expect(canvas.getByText("GitHub")).toBeVisible(); + }, +}; + export const ListSwitchesOrganization: Story = { parameters: { organizations: [MockDefaultOrganization, MockOrganization2], @@ -1240,6 +1280,7 @@ export const UpdateOnlyOrgAdminCanUpdateMCPServer: Story = { await expect(await canvas.findByLabelText(/display name/i)).toHaveValue( "Coder", ); + await userEvent.type(canvas.getByLabelText(/display name/i), " v2"); await expect( canvas.getByRole("button", { name: "Update server" }), ).toBeEnabled(); @@ -1259,7 +1300,7 @@ export const UpdateOnlyOrgAdminCanUpdateMCPServer: Story = { body.queryByRole("option", { name: "User OIDC identity" }), ).not.toBeInTheDocument(); expect( - canvas.queryByRole("button", { name: "Server actions" }), + canvas.queryByRole("button", { name: "Delete" }), ).not.toBeInTheDocument(); expect( canvas.queryByRole("button", { name: /delete server/i }), @@ -1333,7 +1374,6 @@ export const UserOIDCOrgAdminCannotUpdate: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const body = within(canvasElement.ownerDocument.body); await expect(await canvas.findByLabelText(/display name/i)).toHaveValue( "Coder", ); @@ -1357,12 +1397,7 @@ export const UserOIDCOrgAdminCannotUpdate: Story = { await expect( canvas.getByLabelText(/authentication method/i), ).toHaveTextContent("User OIDC identity"); - await userEvent.click( - canvas.getByRole("button", { name: "Server actions" }), - ); - await expect( - await body.findByRole("menuitem", { name: "Remove" }), - ).toBeEnabled(); + await expect(canvas.getByRole("button", { name: "Delete" })).toBeEnabled(); }, }; @@ -1427,12 +1462,7 @@ export const DeleteOnlyOrgAdminCanDeleteWithoutUpdating: Story = { ).toBeDisabled(); await expect(canvas.getByLabelText(/tool allow list/i)).toBeDisabled(); await expect(canvas.getByLabelText(/tool deny list/i)).toBeDisabled(); - await userEvent.click( - canvas.getByRole("button", { name: "Server actions" }), - ); - await userEvent.click( - await body.findByRole("menuitem", { name: "Remove" }), - ); + await userEvent.click(canvas.getByRole("button", { name: "Delete" })); await userEvent.click( await body.findByRole("button", { name: "Delete MCP server" }), ); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx index b7fb6fe0f58..d96f46a3034 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx @@ -99,6 +99,8 @@ const MCPServersPage: FC = () => { )} {organization && ( = ({ onSelectOrganization, }) => { const navigate = useNavigate(); + const [searchQuery, setSearchQuery] = useState(""); + const normalizedQuery = searchQuery.trim().toLowerCase(); + const filteredServers = + normalizedQuery.length === 0 + ? servers + : servers.filter((server) => + [server.display_name, server.slug, server.url] + .join(" ") + .toLowerCase() + .includes(normalizedQuery), + ); // Disambiguate against every organization sharing the page context: // other creation targets and the currently selected organization. const addButtonLabel = @@ -85,13 +101,30 @@ const MCPServersPageView: FC = ({ Agents. - +
+
+ + + + + setSearchQuery(event.target.value)} + /> + +
+ +
{Boolean(error) && (
@@ -103,7 +136,6 @@ const MCPServersPageView: FC = ({ Name Auth Method Availability - Status Open server @@ -130,8 +162,13 @@ const MCPServersPageView: FC = ({ ) : undefined } /> + ) : servers.length > 0 && filteredServers.length === 0 ? ( + ) : ( - servers.map((server) => ( + filteredServers.map((server) => ( = ({ return ( <> Codestin Search App - = ({ isSaving={isSaving} isDeleting={isDeleting} canSelectUserOIDC={canSelectUserOIDC} + organizationPicker={ + + } onUpdateServer={onUpdateServer} onDeleteServer={onDeleteServer} onToggleEnabled={onToggleEnabled} diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx index f7066458779..783eda2d8ed 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx @@ -1,5 +1,5 @@ import { useFormik } from "formik"; -import { type FC, useState } from "react"; +import { type FC, type ReactNode, useState } from "react"; import type * as TypesGen from "#/api/typesGenerated"; import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt"; import { MCPServerFormDialogs } from "./MCPServerFormDialogs"; @@ -21,6 +21,7 @@ type MCPServerFormCreateProps = { isSaving: boolean; isDeleting?: false; canSelectUserOIDC: boolean; + organizationPicker?: ReactNode; onCreateServer: ( req: TypesGen.CreateMCPServerConfigRequest, ) => Promise; @@ -36,6 +37,7 @@ type MCPServerFormEditProps = { isSaving: boolean; isDeleting: boolean; canSelectUserOIDC: boolean; + organizationPicker?: ReactNode; onCreateServer?: undefined; onUpdateServer?: ( serverId: string, @@ -54,6 +56,7 @@ export const MCPServerForm: FC = ({ isSaving, isDeleting = false, canSelectUserOIDC, + organizationPicker, onCreateServer, onUpdateServer, onDeleteServer, @@ -90,7 +93,10 @@ export const MCPServerForm: FC = ({ const isDisabled = isSaving || isDeleting; const areFieldsDisabled = isDisabled || (isEditing && onUpdateServer === undefined); - const canSubmit = canSubmitMCPServerForm(form.values, areFieldsDisabled); + // Editing requires a change before submitting, matching the provider form. + const canSubmit = + canSubmitMCPServerForm(form.values, areFieldsDisabled) && + (!isEditing || form.dirty); const unsavedChanges = useUnsavedChangesPrompt( form.dirty && !form.isSubmitting, ); @@ -120,6 +126,7 @@ export const MCPServerForm: FC = ({ canSubmit={canSubmit} isEditing={isEditing} canSelectUserOIDC={canSelectUserOIDC} + organizationPicker={organizationPicker} onCancel={onCancel} showDetails={showDetails} setShowDetails={setShowDetails} diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx index 99f8aa0f99f..f7b54bf043c 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx @@ -1,5 +1,5 @@ import type { FormikContextType } from "formik"; -import { type FC, useId } from "react"; +import { type FC, type ReactNode, useId } from "react"; import { Button } from "#/components/Button/Button"; import { IconField } from "#/components/IconField/IconField"; import { Input } from "#/components/Input/Input"; @@ -15,6 +15,7 @@ import { SelectValue, } from "#/components/Select/Select"; import { Spinner } from "#/components/Spinner/Spinner"; +import { cn } from "#/utils/cn"; import { MCPServerAuthSection } from "./MCPServerAuthSection"; import { MCPServerBehaviorSection } from "./MCPServerBehaviorSection"; import { CollapsibleSection, Field } from "./MCPServerFormFieldPrimitives"; @@ -31,6 +32,7 @@ interface MCPServerFormFieldsProps { canSubmit: boolean; isEditing: boolean; canSelectUserOIDC: boolean; + organizationPicker?: ReactNode; onCancel?: () => void; showDetails: boolean; setShowDetails: (open: boolean) => void; @@ -47,6 +49,7 @@ export const MCPServerFormFields: FC = ({ canSubmit, isEditing, canSelectUserOIDC, + organizationPicker, onCancel, showDetails, setShowDetails, @@ -65,7 +68,12 @@ export const MCPServerFormFields: FC = ({ autoComplete="off" className="flex flex-col gap-6" > -
+
= ({ disabled={isDisabled} /> -
+ {organizationPicker} +
= ({ onToggleEnabled, }) => { const disabledReasonId = useId(); - const lacksUpdatePermission = !onToggleEnabled; + const lacksUpdatePermission = isEditing && server && !onToggleEnabled; return ( <>
{listPath && } {isEditing && server && onRequestDelete && ( - - - - - - - - Remove - - - +
+ +
)}
-
-
- {isEditing && ( - - )} - - - {title} - - - {isEditing && server && !server.enabled && ( - Disabled - )} -
- {isEditing && server && ( +
+ {isEditing && ( + + )} + + + {title} + + + {isEditing && server && !server.enabled && ( + Disabled + )} +
+ {isEditing && server && ( +
+

+ Disabled servers are hidden from agents. +

@@ -111,16 +96,14 @@ export const MCPServerFormHeader: FC = ({ { - if (onToggleEnabled) { - onToggleEnabled(checked); - } + onToggleEnabled?.(checked); }} disabled={isDisabled} aria-disabled={lacksUpdatePermission} + aria-label="Server enabled" aria-describedby={ lacksUpdatePermission ? disabledReasonId : undefined } - aria-label="Server enabled" className="aria-disabled:cursor-not-allowed aria-disabled:data-[state=checked]:bg-surface-tertiary aria-disabled:data-[state=unchecked]:bg-surface-tertiary" /> @@ -140,8 +123,8 @@ export const MCPServerFormHeader: FC = ({ )} Enable
- )} -
+
+ )} ); }; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx index 29b4e357765..f79a4adc32c 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx @@ -26,7 +26,7 @@ export const MCPServerRow: FC = ({ server, onClick }) => { = ({ server, onClick }) => { > {server.display_name} + {!enabled && ( + + Disabled + + )}
- + {AUTH_TYPE_LABELS[server.auth_type] ?? server.auth_type} - + {AVAILABILITY_LABELS[server.availability] ?? server.availability} - - {enabled ? "Enabled" : "Disabled"} - {onClick && ( diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx index 08444583a68..c3534d0aa4b 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx @@ -1,11 +1,6 @@ import type { FC } from "react"; import type { Organization } from "#/api/typesGenerated"; -import { Label } from "#/components/Label/Label"; -import { - getOrganizationLabel, - OrganizationAutocomplete, -} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; -import { cn } from "#/utils/cn"; +import { OrganizationField } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; interface OrganizationPickerProps { id: string; @@ -14,6 +9,7 @@ interface OrganizationPickerProps { onChange?: (organization: Organization) => void; className?: string; disabled?: boolean; + showLabel?: boolean; showSingleOrganization?: boolean; } @@ -24,41 +20,17 @@ export const OrganizationPicker: FC = ({ onChange, className, disabled, + showLabel = true, showSingleOrganization = false, -}) => { - const hasSingleSelectedOrganization = - organizations.length <= 1 && - organizations.some((option) => option.id === organization.id); - if (hasSingleSelectedOrganization && !showSingleOrganization) { - return null; - } - - // The selected organization can fall outside the selectable options, - // such as a deep link to an organization where servers are listable - // but not creatable, so include it when disambiguating labels. - const labelOrganizations = organizations.some( - (option) => option.id === organization.id, - ) - ? organizations - : [...organizations, organization]; - - return ( -
- - { - if (org) { - onChange?.(org); - } - }} - options={organizations} - labelOrganizations={labelOrganizations} - required - disabled={disabled || !onChange || hasSingleSelectedOrganization} - /> -
- ); -}; +}) => ( + +); diff --git a/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx index 0fb858b0094..dc43bcd2f23 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent, within } from "storybook/test"; +import { expect, fn, screen, userEvent, within } from "storybook/test"; import { deriveProviderStates } from "#/modules/aiModels/providerStates"; import { MockChatModelProviderDescriptor } from "#/testHelpers/chatModels"; import { MockDefaultOrganization, + MockOrganization2, + MockOrganization3, MockOrganizationPermissions, } from "#/testHelpers/entities"; import { withToaster } from "#/testHelpers/storybook"; @@ -23,6 +25,7 @@ const meta: Meta = { ( + + + +); + +export const WithOrganizationPicker: Story = { + decorators: [multiOrgDecorator], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { + name: `Organization ${MockDefaultOrganization.display_name}`, + }), + ); + await expect( + await screen.findByRole("option", { + name: MockOrganization3.display_name, + }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("option", { + name: MockOrganization2.display_name, + }), + ).not.toBeInTheDocument(); + }, +}; + export const WebSearchDependentFields: Story = { args: { selectedProviderState: MockAnthropicProviderState }, play: async ({ canvasElement }) => { @@ -105,18 +155,30 @@ export const ProviderWithoutConfiguredModels: Story = { }; export const ProviderNotFound: Story = { + decorators: [multiOrgDecorator], args: { selectedProviderState: null }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("Provider not found")).toBeInTheDocument(); + await expect( + canvas.getByRole("button", { + name: `Organization ${MockDefaultOrganization.display_name}`, + }), + ).toBeVisible(); }, }; export const LoadError: Story = { + decorators: [multiOrgDecorator], args: { loadError: new Error("Failed to load models") }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("Failed to load models")).toBeVisible(); + await expect( + canvas.getByRole("button", { + name: `Organization ${MockDefaultOrganization.display_name}`, + }), + ).toBeVisible(); }, }; diff --git a/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.tsx b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.tsx index f88e4302773..a82518f7030 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.tsx @@ -1,11 +1,18 @@ import type { FC } from "react"; +import { useLocation, useNavigate, useSearchParams } from "react-router"; import type * as TypesGen from "#/api/typesGenerated"; import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Loader } from "#/components/Loader/Loader"; +import { OrganizationField } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; import type { ProviderState } from "#/modules/aiModels/providerStates"; import { ModelForm } from "../components/ModelForm"; import { ModelFormBackLink } from "../components/ModelFormHeader"; +import { + creatableModelOrganizations, + selectModelOrganizationPath, + useOrganizationModels, +} from "../organizationModels"; interface AddModelPageViewProps { isLoading: boolean; @@ -32,6 +39,35 @@ const AddModelPageView: FC = ({ onProviderChange, onCreateModel, }) => { + const { organization, accessibleOrganizations, permissionsByOrganization } = + useOrganizationModels(); + const location = useLocation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const creatableOrganizations = creatableModelOrganizations( + accessibleOrganizations, + permissionsByOrganization, + ); + const organizationPicker = creatableOrganizations.length > 1 && ( + { + void navigate( + selectModelOrganizationPath( + location.pathname, + nextOrganization, + searchParams, + ), + ); + }} + /> + ); + if (isLoading) { return ; } @@ -41,6 +77,7 @@ const AddModelPageView: FC = ({
+ {organizationPicker}
); } @@ -56,6 +93,7 @@ const AddModelPageView: FC = ({ Please try again. + {organizationPicker}
); } diff --git a/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx index 924ab98fe2c..8cb589cae73 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx @@ -29,6 +29,7 @@ const meta: Meta = { = ({ }) => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const { organization } = useOrganizationModels(); + const { organization, accessibleOrganizations } = useOrganizationModels(); const [page, setPage] = useState(1); const [searchQuery, setSearchQuery] = useState(""); const [providerFilter, setProviderFilter] = @@ -247,6 +249,26 @@ const ModelsPageView: FC = ({ />
+ {accessibleOrganizations.length > 1 && ( + { + void navigate( + selectModelOrganizationPath( + "/ai/settings/models", + nextOrganization, + searchParams, + ), + ); + }} + /> + )}