From 20c32135e8338da0fdf446481231fdc8cc036ace Mon Sep 17 00:00:00 2001 From: TJ Date: Mon, 24 Aug 2026 22:58:51 -0700 Subject: [PATCH 01/12] refactor(site/src): delete DateTimeRangeFilter and unify on the picker's time range type (#28427) Completes the deduplication agreed in #28392 (review): now that `DateTimeRangePicker` has replaced the text-expression `DateTimeRangeFilter` on the AI Gateway sessions page, the old component and its `TimeRange` type are deleted, leaving a single time-range value shape. - Deletes `site/src/components/DateTimeRangeFilter/` (component, stories, `timeRange.ts` helpers, tests); nothing imported it except the sessions page type alias. - The sessions page helpers in `ListSessionsPage/timeRange.ts` now use neutral `{start, end}` naming derived from the picker's `DateTimeRangeValue` (`type TimeRange = Pick`), removing the `startedAfter`/`startedBefore` field mapping the page previously did by hand. No behavior changes: the query-string serialization (`started_after`/`started_before` RFC 3339 params) is untouched, and all existing unit tests pass with only field renames. --- Generated by Coder Agents on behalf of @tracyjohnsonux. (cherry picked from commit 607a1d066665ddeabcf7622542f0f1328820e4a9) --- .../DateTimeRangeFilter.stories.tsx | 268 ------------------ .../DateTimeRangeFilter.tsx | 240 ---------------- .../DateTimeRangeFilter/timeRange.test.ts | 125 -------- .../DateTimeRangeFilter/timeRange.ts | 93 ------ .../ListSessionsPage/ListSessionsPage.tsx | 15 +- .../ListSessionsPage/timeRange.test.ts | 20 +- .../ListSessionsPage/timeRange.ts | 28 +- 7 files changed, 28 insertions(+), 761 deletions(-) delete mode 100644 site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.stories.tsx delete mode 100644 site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.tsx delete mode 100644 site/src/components/DateTimeRangeFilter/timeRange.test.ts delete mode 100644 site/src/components/DateTimeRangeFilter/timeRange.ts diff --git a/site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.stories.tsx b/site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.stories.tsx deleted file mode 100644 index 52b8a38f1d2d3..0000000000000 --- 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 d37159bf95e6e..0000000000000 --- 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 616373598444c..0000000000000 --- 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 7185e18360d3f..0000000000000 --- 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/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx index fcf6233816dc7..72ccf73421135 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 b2a00ea81ecfc..47102f0bf85ca 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 1d33e59719a3e..6de07c7020e50 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 }; }; From 78875ce38e910be0f6543479d808f789f427ef9a Mon Sep 17 00:00:00 2001 From: TJ Date: Tue, 25 Aug 2026 00:21:12 -0700 Subject: [PATCH 02/12] refactor(site/src/pages/AIBridgePage): move AI Sessions docs link inline into subtitle (#28481) Replaces the "Read the docs" header button on the AI Sessions page with an inline "View docs" link at the end of the subtitle sentence, matching the pattern used on the Providers and Provisioner Jobs pages. --- _Created by a Coder Agent on behalf of the PR author._ (cherry picked from commit 0452ff7ec4553ed2f7bdd26c42deb60137dcbce6) --- .../src/pages/AIBridgePage/AIBridgeSessionsLayout.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/site/src/pages/AIBridgePage/AIBridgeSessionsLayout.tsx b/site/src/pages/AIBridgePage/AIBridgeSessionsLayout.tsx index 31a27c1a1a4f3..57c2d20d1ab5a 100644 --- a/site/src/pages/AIBridgePage/AIBridgeSessionsLayout.tsx +++ b/site/src/pages/AIBridgePage/AIBridgeSessionsLayout.tsx @@ -1,22 +1,18 @@ import type { FC, PropsWithChildren } from "react"; import { Outlet } from "react-router"; +import { Link } from "#/components/Link/Link"; import { Margins } from "#/components/Margins/Margins"; import { PageHeader, PageHeaderSubtitle, PageHeaderTitle, } from "#/components/PageHeader/PageHeader"; -import { SettingsHeaderDocsLink } from "#/components/SettingsHeader/SettingsHeader"; import { docs } from "#/utils/docs"; const AIBridgeSessionsLayout: FC = () => { 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 From 29b44bf75474357920b352898cdca6644958a1b3 Mon Sep 17 00:00:00 2001 From: TJ Date: Tue, 25 Aug 2026 00:21:53 -0700 Subject: [PATCH 03/12] fix(site/src/pages/AIBridgePage): align token badges with neighboring badge sizing (#28477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in/out token pills on the AI Gateway sessions pages looked out of place next to the provider/client/model badges: they used `Badge size="sm"` while their neighbors use the default `md`, and their arrow icons were `size-icon-lg` (24px) — taller than the badge itself, inflating the pill height. > [!NOTE] > The underlying font-size problem (`Badge sm` = 10px `text-2xs`) is fixed globally in #28517. This PR handles what that one doesn't: the token pills' size variant, icon sizing, and API cleanup. ## Changes - Token pills use Badge's default `md` size, matching the provider/client/model badges next to them. - Arrow icons drop from `size-icon-lg` (24px) to `size-icon-xs` (14px), the same icon size the neighboring badges use, with `gap-0.5` between icon and count. - Removed the unused `size` prop from `TokenBadges` — no call site ever passed one — and dropped the now-meaningless `SizeXs`/`SizeMd` stories. Affects every `TokenBadges` consumer: the sessions list, the session summary card, and the timeline prompt/tool-call tables. > 🤖 Generated by Coder Agents on behalf of @tracyjohnsonux (cherry picked from commit e98e2b9b2ea2679c393b4753b688bb0bd778ad7a) --- .../pages/AIBridgePage/TokenBadges.stories.tsx | 16 ---------------- site/src/pages/AIBridgePage/TokenBadges.tsx | 13 ++++--------- 2 files changed, 4 insertions(+), 25 deletions(-) diff --git a/site/src/pages/AIBridgePage/TokenBadges.stories.tsx b/site/src/pages/AIBridgePage/TokenBadges.stories.tsx index 109c310da276f..044c97bc58cf2 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 dd4b39ce56201..50e30a8797b2a 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)} From 91f8fe8f6d6e0b5883b87b534d894664b7e54fef Mon Sep 17 00:00:00 2001 From: TJ Date: Tue, 25 Aug 2026 08:34:29 -0700 Subject: [PATCH 04/12] refactor(site): use robot icon for subagent toggle in chat kebab menu (#28561) Replaces the rotated `GitForkIcon` on the "Show/Hide subagents" item in the chat kebab menu with lucide's `BotIcon`, matching the robot icon already used for subagents in the chat stream (`SubagentTool`, `ToolIcon`). --- *This PR was generated by Coder Agents on behalf of @tracyjohnsonux.* (cherry picked from commit 94f312dfbdd5209073c9ef6298ec88d1467f740a) --- site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx b/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx index 9d1a6bb678278..d4769c2fe46fe 100644 --- a/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx +++ b/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx @@ -1,7 +1,7 @@ import { ArchiveIcon, ArchiveRestoreIcon, - GitForkIcon, + BotIcon, PinIcon, PinOffIcon, SquarePenIcon, @@ -109,7 +109,7 @@ export const ChatActionsMenuItems: FC = ({ const subagentToggle = showSubagentsToggle ? ( - + {isSubagentsExpanded ? "Hide subagents" : `Show subagents (${subagentCount})`} From 8203e4aa811b5c68ed4f75db85b8c028f2153843 Mon Sep 17 00:00:00 2001 From: TJ Date: Tue, 25 Aug 2026 09:16:59 -0700 Subject: [PATCH 05/12] fix: use medium badges and shorter shared key label on agent API keys page (#28557) On `/agents/settings/api-keys`, bumps the provider key status badge and the enabled model badges from `sm`/`xs` to the medium (`md`) size, and shortens the status label "Using shared key" to "Shared key". Updates the matching Storybook assertion and the status indicator name in `docs/ai-coder/agents/models.md`. Verified with `tsc` and the page's Storybook tests (15 passing). --- *Created by Coder Agents on behalf of @tracyjohnsonux.* (cherry picked from commit c07bde80c428cb8e5e1afa35e9799e3af449172e) --- docs/ai-coder/agents/models.md | 2 +- .../pages/AgentsPage/AgentSettingsAPIKeysPage.stories.tsx | 2 +- site/src/pages/AgentsPage/AgentSettingsAPIKeysPageView.tsx | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/ai-coder/agents/models.md b/docs/ai-coder/agents/models.md index 1f59c29ad3b09..1eaefc880307a 100644 --- a/docs/ai-coder/agents/models.md +++ b/docs/ai-coder/agents/models.md @@ -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/pages/AgentsPage/AgentSettingsAPIKeysPage.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsAPIKeysPage.stories.tsx index 1b26a877da6e9..eb7fe968b450e 100644 --- a/site/src/pages/AgentsPage/AgentSettingsAPIKeysPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsAPIKeysPage.stories.tsx @@ -365,7 +365,7 @@ export const ShowsProviderStatuses: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(await canvas.findByText("Key saved")).toBeVisible(); - await expect(canvas.getByText("Using shared key")).toBeVisible(); + await expect(canvas.getByText("Shared key")).toBeVisible(); await expect(canvas.getByText("No key")).toBeVisible(); await expect( canvas.getByText( diff --git a/site/src/pages/AgentsPage/AgentSettingsAPIKeysPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsAPIKeysPageView.tsx index 75517d2c7c03b..c90bf64e5e1d1 100644 --- a/site/src/pages/AgentsPage/AgentSettingsAPIKeysPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsAPIKeysPageView.tsx @@ -38,7 +38,7 @@ const getProviderStatus = ( if (provider.has_central_api_key_fallback) { return { - label: "Using shared key", + label: "Shared key", variant: "default", note: "The shared deployment key is being used. Add a personal key to use your own.", }; @@ -133,7 +133,7 @@ const ProviderKeyPanel: FC = ({

{status.note}

)}
- + {status.label} @@ -207,7 +207,7 @@ const ProviderKeyPanel: FC = ({ ) : enabledModels.length > 0 ? (
{enabledModels.map((model) => ( - + {model.display_name || model.model} ))} From 16d6aac047785828b094edd9c283f4db84615bbb Mon Sep 17 00:00:00 2001 From: TJ Date: Tue, 25 Aug 2026 10:37:14 -0700 Subject: [PATCH 06/12] fix(site/src/pages/AgentsPage): use outlined empty state and kebab menu for personal skills (#28560) Polishes the `/agents/settings/personal-skills` page to match the table patterns used elsewhere (e.g. the AI Gateway keys page): - The table now renders in every state, so the content area always keeps its outline: `TableLoader` while loading, a `TableEmpty` with a Retry CTA on error (with the `ErrorAlert` above), and a `TableEmpty` with an Add skill CTA when there are no skills. - The per-row Download / Edit / Delete buttons are replaced with a kebab (`EllipsisVertical`) dropdown menu, following the pattern in `CustomRolesPageView`. The trigger shows a spinner and disables while a download is in flight, and the Actions column header is now screen-reader only. Storybook interaction coverage: `RowMenuActions` opens the kebab and exercises Download, Edit, and Delete; `DownloadingSkill` asserts the disabled trigger.
Review notes Self-audited the diff against the FE1-FE10 rules in `.claude/docs/FRONTEND_PATTERNS.md`: all rules pass. All 22 stories pass locally, along with Biome and `tsc`.
--- Generated by Coder Agents on behalf of @tracyjohnsonux. (cherry picked from commit 74e5a680bd16312bda5e248f33dc936bed0b9240) --- ...SettingsPersonalSkillsPageView.stories.tsx | 61 ++++++- .../AgentSettingsPersonalSkillsPageView.tsx | 166 ++++++++++-------- 2 files changed, 152 insertions(+), 75 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.stories.tsx index de1eb4ad987d6..979ff10051ff4 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.stories.tsx @@ -58,6 +58,21 @@ export const DownloadingSkill: Story = { args: { downloadingSkillName: "review-sql", }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + const row = canvas.getByRole("row", { name: /review-sql/ }); + await userEvent.click( + within(row).getByRole("button", { name: "Open menu" }), + ); + const menu = await body.findByRole("menu"); + await expect( + within(menu).getByRole("menuitem", { name: "Download" }), + ).toHaveAttribute("aria-disabled", "true"); + await expect( + within(menu).getByRole("menuitem", { name: "Edit" }), + ).not.toHaveAttribute("aria-disabled"); + }, }; export const ExportingAll: Story = { @@ -66,19 +81,46 @@ export const ExportingAll: Story = { }, }; -export const DownloadsSkill: Story = { +export const RowMenuActions: Story = { play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); const row = canvas.getByRole("row", { name: /review-sql/ }); + const trigger = within(row).getByRole("button", { name: "Open menu" }); + + await userEvent.click(trigger); await userEvent.click( - within(row).getByRole("button", { name: "Download" }), + within(await body.findByRole("menu")).getByRole("menuitem", { + name: "Download", + }), ); - await waitFor(() => { expect(args.onDownload).toHaveBeenCalledWith( expect.objectContaining({ name: "review-sql" }), ); }); + + await userEvent.click(trigger); + await userEvent.click( + within(await body.findByRole("menu")).getByRole("menuitem", { + name: "Edit", + }), + ); + await waitFor(() => { + expect(args.onEdit).toHaveBeenCalledWith("review-sql"); + }); + + await userEvent.click(trigger); + await userEvent.click( + within(await body.findByRole("menu")).getByRole("menuitem", { + name: /Delete/, + }), + ); + await waitFor(() => { + expect(args.onDelete).toHaveBeenCalledWith( + expect.objectContaining({ name: "review-sql" }), + ); + }); }, }; @@ -113,6 +155,19 @@ export const ListError: Story = { }, }; +export const RefetchErrorKeepsRows: Story = { + args: { + error: new Error("Failed to load personal skills."), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("row", { name: /review-sql/ })).toBeVisible(); + await expect( + canvas.getByText("Failed to load personal skills."), + ).toBeVisible(); + }, +}; + export const CreateDialogOpen: Story = { args: { editorState: { diff --git a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.tsx index 62ebaf71585cb..33ec027a0ee68 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.tsx @@ -1,3 +1,4 @@ +import { EllipsisVerticalIcon, PlusIcon } from "lucide-react"; import type { FC } from "react"; import type { UserSkillMetadata } from "#/api/typesGenerated"; import { Alert, AlertDescription } from "#/components/Alert/Alert"; @@ -12,7 +13,13 @@ import { DialogHeader, DialogTitle, } from "#/components/Dialog/Dialog"; -import { EmptyState } from "#/components/EmptyState/EmptyState"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "#/components/DropdownMenu/DropdownMenu"; import { Loader } from "#/components/Loader/Loader"; import { Spinner } from "#/components/Spinner/Spinner"; import { @@ -23,6 +30,8 @@ import { TableHeader, TableRow, } from "#/components/Table/Table"; +import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; +import { TableLoader } from "#/components/TableLoader/TableLoader"; import { formatDate } from "#/utils/time"; import type { PersonalSkillErrorDisplay } from "./components/PersonalSkillEditor"; import { PersonalSkillEditor } from "./components/PersonalSkillEditor"; @@ -221,14 +230,18 @@ export const AgentSettingsPersonalSkillsPageView: FC< }) => { const isAtLimit = skills.length >= PERSONAL_SKILLS_MAX_PER_USER; const addSkillAction = ( - ); const headerActions = (
-
- ) : isLoading ? ( - - ) : skills.length === 0 ? ( - - ) : ( - - - - Name - Description - Updated - Actions - - - - {skills.map((skill) => ( + {Boolean(error) && } + +
+ + + Name + Description + Updated + + Actions + + + + + {isLoading ? ( + + ) : skills.length === 0 && error ? ( + + {isRetrying && } + Retry + + } + /> + ) : skills.length === 0 ? ( + + ) : ( + skills.map((skill) => ( - - {skill.name} - + {skill.name} {skill.description || ( - + No description )} {formatUpdatedAt(skill.updated_at)} - -
- - - -
+ + + + + + + onDownload(skill)} + disabled={downloadingSkillName === skill.name} + > + Download + + onEdit(skill.name)}> + Edit + + + onDelete(skill)} + > + Delete… + + +
- ))} -
-
- )} + )) + )} + + {editorState?.mode === "create" && ( Date: Wed, 26 Aug 2026 10:50:24 -0700 Subject: [PATCH 07/12] 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._ (cherry picked from commit 2eee703ec4af2acc532c23297b7a891407b054db) --- .../AgentSettingsCompactionPage.tsx | 9 +- ...gentSettingsCompactionPageView.stories.tsx | 4 +- .../AgentSettingsCompactionPageView.tsx | 6 +- ...serCompactionThresholdSettings.stories.tsx | 154 ++++++++++++++++-- .../UserCompactionThresholdSettings.tsx | 100 +++++++++--- 5 files changed, 222 insertions(+), 51 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx b/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx index 9654154dea71f..16a338e6b1e68 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 bdf4e0755d085..5c9ecdd97a4f2 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 75ae171be0237..61136d266f2d3 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 4a32d00fe9534..a2c9fc5524c65 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"}`} From 86a162c0a1c1c722fd8e0f618276412cca6c5a16 Mon Sep 17 00:00:00 2001 From: TJ Date: Wed, 26 Aug 2026 12:37:13 -0700 Subject: [PATCH 08/12] fix(site/src): use md badges for provisioner tags and network call pills (#28527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several badges render at `text-2xs` (10px) via `Badge size="sm"` and read as broken next to their surroundings, especially since the MUI/Emotion removal (#27821) dropped the 14px `CssBaseline` body typography that used to soften the contrast. Most visible on the provisioner jobs page (Type and Tags columns) and the AI Gateway sessions list (network call pills). Rather than changing what `Badge sm` means globally (explored in #28517, closed in favor of this), this switches the affected call sites to the default `md` size (`text-xs`, 12px), matching the other badges around them. ## Changes - `ProvisionerTags.tsx`: `ProvisionerTag` and the `+N` overflow badge → default `md`. Covers the provisioner jobs Tags column, job detail expansion, and other provisioner tag consumers. - `JobRow.tsx`: the job Type badge → default `md`. - `NetworkCallBadges.tsx`: the total/blocked pills on the sessions list → default `md`, consistent with the token pills in #28477. The `Badge` component itself is untouched; `sm` remains available for intentionally dense contexts. Related: #28477 (AI session token badges). > 🤖 Generated by Coder Agents on behalf of @tracyjohnsonux (cherry picked from commit f31b759776b0d25ab5fcb94db51f4c61ec612b81) --- site/src/modules/provisioners/ProvisionerTags.tsx | 4 ++-- site/src/pages/AIBridgePage/NetworkCallBadges.tsx | 3 +-- .../OrganizationProvisionerJobsPage/JobRow.tsx | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/site/src/modules/provisioners/ProvisionerTags.tsx b/site/src/modules/provisioners/ProvisionerTags.tsx index 07fc70b9eb1d2..ac91365492707 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/pages/AIBridgePage/NetworkCallBadges.tsx b/site/src/pages/AIBridgePage/NetworkCallBadges.tsx index 2da53ba97cd45..7a05f919fe902 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/OrganizationSettingsPage/OrganizationProvisionerJobsPage/JobRow.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/JobRow.tsx index f3e88e1262a9f..01e2d8dd8588e 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/JobRow.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/JobRow.tsx @@ -54,7 +54,7 @@ export const JobRow: FC = ({ job, defaultIsOpen = false }) => { - {job.type} + {job.type} {job.metadata.template_name !== "" ? ( From b73cf76dc63e451025c209eb42b406025ebd74e0 Mon Sep 17 00:00:00 2001 From: TJ Date: Wed, 26 Aug 2026 12:40:49 -0700 Subject: [PATCH 09/12] fix(site/src): use default combobox dropdown surface (#28613) Combobox dropdowns were forcing the migrated menu styling in a few places: secondary menu surfaces, surface-quaternary borders, square item highlights, tertiary selected-row highlights, and a tertiary footer hover in the workspace template dropdown. Use the shared combobox defaults instead: primary menu surfaces, `border-border-default`, padded lists so highlights do not run to the edge, rounded item highlights, and the standard secondary selected-row highlight. Remove callsite overrides that were preventing the shared defaults from applying to workspace, filter, and version dropdowns. > This PR was generated by Coder Agents on behalf of @tracyjohnsonux. (cherry picked from commit 2eb9e4fbe8b283726891406d23ea483664f1c857) --- site/src/components/Combobox/Combobox.tsx | 14 ++++++++------ site/src/components/Filter/SelectFilter.tsx | 9 ++------- .../ChangeWorkspaceVersionDialog.tsx | 6 +----- site/src/pages/WorkspacesPage/WorkspacesButton.tsx | 6 +++--- 4 files changed, 14 insertions(+), 21 deletions(-) diff --git a/site/src/components/Combobox/Combobox.tsx b/site/src/components/Combobox/Combobox.tsx index d08e56ac19cb5..347c4322e346f 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/Filter/SelectFilter.tsx b/site/src/components/Filter/SelectFilter.tsx index 339e8aa7e8310..85ff2ed974a09 100644 --- a/site/src/components/Filter/SelectFilter.tsx +++ b/site/src/components/Filter/SelectFilter.tsx @@ -75,16 +75,11 @@ export const SelectFilter: FC = ({ shouldFilter={false} > {selectFilterSearch} - + {options !== undefined ? ( options.map((option) => ( = ({ = ({ key={template.id} value={template.id} keywords={[template.display_name, template.name]} - className="px-4 data-[selected=true]:bg-surface-tertiary font-normal gap-3 [&>svg:last-child]:hidden" + className="px-4 font-normal gap-3 [&>svg:last-child]:hidden" > = ({

See all templates From 431e8aaf862c92baf28056106bc8a1e869afaffb Mon Sep 17 00:00:00 2001 From: TJ Date: Wed, 26 Aug 2026 13:18:05 -0700 Subject: [PATCH 10/12] fix(site): match sessions date/time picker icon to search field icon (#28478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the date/time range picker trigger on the AI Gateway sessions page visually consistent with the adjacent search field and filter dropdowns. Button size is unchanged (`lg`, h-10). - **Calendar icon size:** now `size-icon-sm` (18px), identical to the search icon. Previously the Button's `[&>svg]:size-icon-lg` + `[&>svg]:p-0.5` child selectors (specificity 0-1-1) overrode the icon's authored `size-4` class (0-1-0), inflating it to a 24px box at `size="lg"`. The calendar's classes are marked important (`!size-icon-sm !p-0`) to win that fight. - **Calendar line weight:** at equal geometry the calendar reads heavier than the magnifier because its glyph is denser, so its stroke is `strokeWidth={1.75}` for optical parity with the search icon. - **Chevron:** now `size-icon-sm` with no color class, inheriting the button's `text-content-primary` — exactly matching `ComboboxButton`, which backs the other filter dropdowns (previously it was `text-content-secondary`). - **Spacing:** trigger uses `gap-2 pr-1.5` like `ComboboxButton`, giving 8px icon-to-text (matching the search field's `pr-2` addon spacing) and the same label-to-chevron gap and right padding as the other dropdowns. **Scope:** `DateTimeRangePicker`'s only production consumer is the AI Gateway sessions filter, so the change affects only this page (plus Storybook). --- *This PR was generated by Coder Agents on behalf of @tracyjohnsonux.* (cherry picked from commit 769decbe2124c7f3cff00d5a1145562190d3fe3c) --- .../DateTimeRangePicker/DateTimeRangePicker.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx index fe551b5289247..f3c1d7e12079c 100644 --- a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx +++ b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx @@ -211,10 +211,12 @@ export const DateTimeRangePicker: FC = ({ return ( - Date: Wed, 26 Aug 2026 10:13:19 -0700 Subject: [PATCH 11/12] chore(site/src): move deployment docs links inline as View docs text links (#28612) Moves documentation CTAs that were previously rendered as header buttons into inline `View docs` external text links under the relevant header or section copy. Deployment settings: - `/deployment/overview` - `/deployment/appearance` - `/deployment/external-auth` - `/deployment/oauth2-provider/apps` - `/deployment/network` - `/deployment/workspace-proxies` - `/deployment/idp-org-sync` - `/deployment/notifications` - `/deployment/userauth` - `/deployment/security` - `/deployment/observability` - `/deployment/premium` - `/deployment/ai-governance` Organization and user settings: - `/organizations/:organization/groups` - `/organizations/:organization/roles` - `/organizations/:organization/idp-sync` - `/organizations/new` - `/organizations/:organization/provisioners` - `/organizations/:organization/provisioner-keys` - workspace sharing settings Other admin/tool pages: - `/ai/settings/gateway-keys` - `/audit` - `/connectionlog` - template permissions settings - `SettingsHeaderDocsLink` now renders the shared inline `Link` component with the default label `View docs`, external icon, and screen-reader text for new-tab behavior. - Right-side header actions now remain reserved for real actions such as `Add application`, `Create key`, or export buttons. - `/deployment/security`: removed the duplicate Browser-Only Connections badge and vertically aligned the remaining badge with the section heading. - `/deployment/observability`: kept docs links scoped to Audit Logging and Monitoring, and removed the top-level observability docs link. - Notification misconfiguration alerts now use the same `View docs` text-link treatment. - Storybook stories updated to assert the new link labels and hrefs where covered. - `pnpm check` - `pnpm lint:types` - Targeted Storybook interaction tests for the updated pages. _This PR was generated by Coder Agents on behalf of @tracyjohnsonux._ (cherry picked from commit 046a532a18d728a2b10618304e7383d62c14e882) --- .../SettingsHeader/SettingsHeader.tsx | 23 ++++++------ .../GatewayKeysPageView.stories.tsx | 2 +- .../GatewayKeysPage/GatewayKeysPageView.tsx | 24 ++++++------- .../pages/AuditPage/AuditPageView.stories.tsx | 2 +- site/src/pages/AuditPage/AuditPageView.tsx | 11 +++--- .../ConnectionLogPageView.stories.tsx | 2 +- .../ConnectionLogPageView.tsx | 13 +++---- .../AIGovernanceSettingsPageView.tsx | 9 ++--- .../AppearanceSettingsPageView.stories.tsx | 2 +- .../AppearanceSettingsPageView.tsx | 9 ++--- .../ExternalAuthSettingsPageView.stories.tsx | 4 +++ .../ExternalAuthSettingsPageView.tsx | 7 ++-- .../IdpOrgSyncPage/IdpOrgSyncPage.tsx | 14 +++----- .../NetworkSettingsPageView.stories.tsx | 18 +++++++++- .../NetworkSettingsPageView.tsx | 24 ++++++------- .../NotificationEvents.stories.tsx | 18 +++++++++- .../NotificationsPage/NotificationEvents.tsx | 36 +++++++++---------- .../NotificationsPage.stories.tsx | 17 ++++++++- .../NotificationsPage/NotificationsPage.tsx | 12 +++---- .../OAuth2AppsSettingsPageView.stories.tsx | 4 +-- .../OAuth2AppsSettingsPageView.tsx | 24 +++++-------- .../ObservabilitySettingsPageView.stories.tsx | 13 +++++-- .../ObservabilitySettingsPageView.tsx | 18 ++++++---- .../OverviewPage/OverviewPageView.stories.tsx | 11 +++++- .../OverviewPage/OverviewPageView.tsx | 7 ++-- .../PremiumPage/PremiumPage.tsx | 13 +++---- .../SecuritySettingsPageView.tsx | 29 ++++++++------- .../UserAuthSettingsPageView.stories.tsx | 18 +++++++++- .../UserAuthSettingsPageView.tsx | 24 ++++++------- site/src/pages/GroupsPage/GroupsPage.tsx | 9 ++--- .../CreateOrganizationPageView.stories.tsx | 2 +- .../CreateOrganizationPageView.tsx | 8 ++--- .../CustomRolesPage/CustomRolesPage.tsx | 9 ++--- .../IdpSyncPage/IdpSyncPage.tsx | 9 ++--- ...izationProvisionerJobsPageView.stories.tsx | 13 ++++++- .../OrganizationProvisionerJobsPageView.tsx | 4 ++- ...izationProvisionerKeysPageView.stories.tsx | 2 +- .../OrganizationProvisionerKeysPageView.tsx | 7 ++-- ...ganizationProvisionersPageView.stories.tsx | 2 +- .../OrganizationProvisionersPageView.tsx | 7 ++-- .../TemplatePermissionsPage.tsx | 11 +++--- .../SecretsPage/SecretsPageView.stories.tsx | 2 +- .../SecretsPage/SecretsPageView.tsx | 26 +++++--------- .../WorkspaceProxyView.stories.tsx | 7 ++++ .../WorkspaceProxyPage/WorkspaceProxyView.tsx | 13 +++---- .../WorkspaceSharingPageView.tsx | 11 +++--- 46 files changed, 298 insertions(+), 252 deletions(-) diff --git a/site/src/components/SettingsHeader/SettingsHeader.tsx b/site/src/components/SettingsHeader/SettingsHeader.tsx index fcac611aa85d4..cd5015a9bc756 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/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.stories.tsx b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.stories.tsx index cf2d61697703d..6b94315e25286 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 2b12c6d2c2750..9d5f269d32949 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/AuditPage/AuditPageView.stories.tsx b/site/src/pages/AuditPage/AuditPageView.stories.tsx index cba54475f79c1..929161e0ea035 100644 --- a/site/src/pages/AuditPage/AuditPageView.stories.tsx +++ b/site/src/pages/AuditPage/AuditPageView.stories.tsx @@ -103,7 +103,7 @@ export const NotVisible: 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("/admin/security/audit-logs")); }, }; diff --git a/site/src/pages/AuditPage/AuditPageView.tsx b/site/src/pages/AuditPage/AuditPageView.tsx index 0ed109f0ec0dd..21502b02abab2 100644 --- a/site/src/pages/AuditPage/AuditPageView.tsx +++ b/site/src/pages/AuditPage/AuditPageView.tsx @@ -51,18 +51,17 @@ export const AuditPageView: FC = ({ return ( - - } - > +
Audit
- View events in your audit log. + + View events in your audit log.{" "} + +
{isAuditLogVisible ? ( diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogPageView.stories.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogPageView.stories.tsx index ff238731a5cb6..f3c5028017099 100644 --- a/site/src/pages/ConnectionLogPage/ConnectionLogPageView.stories.tsx +++ b/site/src/pages/ConnectionLogPage/ConnectionLogPageView.stories.tsx @@ -102,7 +102,7 @@ export const NotVisible: 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("/admin/monitoring/connection-logs")); }, }; diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx index 520aaddbdbaee..5f4282470d036 100644 --- a/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx +++ b/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx @@ -50,13 +50,7 @@ export const ConnectionLogPageView: FC = ({ return ( - - } - > +
Connection Log @@ -64,7 +58,10 @@ export const ConnectionLogPageView: FC = ({
- View workspace connection events. + View workspace connection events.{" "} +
diff --git a/site/src/pages/DeploymentSettingsPage/AIGovernanceSettingsPage/AIGovernanceSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/AIGovernanceSettingsPage/AIGovernanceSettingsPageView.tsx index f83663e684ec9..b7d69818b675a 100644 --- a/site/src/pages/DeploymentSettingsPage/AIGovernanceSettingsPage/AIGovernanceSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/AIGovernanceSettingsPage/AIGovernanceSettingsPageView.tsx @@ -29,16 +29,13 @@ export const AIGovernanceSettingsPageView: FC<
- - } - > + AI Gateway - Monitor and manage AI requests across your deployment. + Monitor and manage AI requests across your deployment.{" "} + diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx index bc81d061c798f..dbdb2aba3ae89 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx @@ -58,7 +58,7 @@ export const NotEntitled: 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("/admin/setup/appearance")); await expect( canvas.queryByRole("form", { name: "Appearance settings" }), diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx index 7c5484aa31814..f17a113a983fb 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx @@ -49,14 +49,11 @@ export const AppearanceSettingsPageView: FC< return (
- - } - > + Appearance - Customize the look and feel of your Coder deployment. + Customize the look and feel of your Coder deployment.{" "} + diff --git a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx index 0b5ac04cd8c78..8880f583ea382 100644 --- a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, within } from "storybook/test"; +import { docs } from "#/utils/docs"; import { ExternalAuthSettingsPageView } from "./ExternalAuthSettingsPageView"; const meta: Meta = { @@ -49,6 +50,9 @@ export const Page: Story = { await expect( canvas.getByRole("link", { name: "Start trial for free" }), ).toHaveAttribute("href", "/deployment/premium"); + await expect( + canvas.getByRole("link", { name: /View docs/ }), + ).toHaveAttribute("href", docs("/admin/external-auth")); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx index c27648515e27a..8c5cb354762e1 100644 --- a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx @@ -33,13 +33,12 @@ export const ExternalAuthSettingsPageView: FC< > = ({ config, isEntitled, canViewPremium }) => { return ( <> - } - > + External Authentication Coder integrates with GitHub, GitLab, BitBucket, Azure Repos, and - OpenID Connect to authenticate developers with external services. + OpenID Connect to authenticate developers with external services.{" "} + diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx index 56f5ef031cec1..48163500d239e 100644 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx @@ -70,19 +70,15 @@ const IdpOrgSyncPage: FC = () => {
- - -
- } + actions={} > Organization IdP Sync Automatically assign users to an organization based on their IdP - claims. + claims.{" "} +
{!isIdpSyncEnabled ? ( diff --git a/site/src/pages/DeploymentSettingsPage/NetworkSettingsPage/NetworkSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/NetworkSettingsPage/NetworkSettingsPageView.stories.tsx index 6aa7dc2c5c915..9c8f1c9465d31 100644 --- a/site/src/pages/DeploymentSettingsPage/NetworkSettingsPage/NetworkSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/NetworkSettingsPage/NetworkSettingsPageView.stories.tsx @@ -1,5 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; import type { SerpentGroup } from "#/api/typesGenerated"; +import { docs } from "#/utils/docs"; import { NetworkSettingsPageView } from "./NetworkSettingsPageView"; const group: SerpentGroup = { @@ -67,4 +69,18 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Page: Story = {}; +export const Page: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const docsLinks = canvas.getAllByRole("link", { name: /View docs/ }); + await expect(docsLinks).toHaveLength(2); + await expect(docsLinks[0]).toHaveAttribute( + "href", + docs("/admin/networking"), + ); + await expect(docsLinks[1]).toHaveAttribute( + "href", + docs("/admin/networking/port-forwarding"), + ); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/NetworkSettingsPage/NetworkSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/NetworkSettingsPage/NetworkSettingsPageView.tsx index aa218f6f88d4c..b545af5497a71 100644 --- a/site/src/pages/DeploymentSettingsPage/NetworkSettingsPage/NetworkSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/NetworkSettingsPage/NetworkSettingsPageView.tsx @@ -27,12 +27,14 @@ export const NetworkSettingsPageView: FC = ({ }) => (
- } - > + Network - Configure your deployment connectivity. + Configure your deployment connectivity.{" "} + @@ -44,19 +46,17 @@ export const NetworkSettingsPageView: FC = ({
- - } - > + Port Forwarding Port forwarding lets developers securely access processes on their - Coder workspace from a local machine. + Coder workspace from a local machine.{" "} + diff --git a/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.stories.tsx b/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.stories.tsx index 131e29f12234a..58bf9fdf4e2b2 100644 --- a/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { spyOn, userEvent, within } from "storybook/test"; +import { expect, spyOn, userEvent, within } from "storybook/test"; import { API } from "#/api/api"; import { selectTemplatesByGroup } from "#/api/queries/notifications"; import type { DeploymentValues } from "#/api/typesGenerated"; import { MockSystemNotificationTemplates } from "#/testHelpers/entities"; +import { docs } from "#/utils/docs"; import { NotificationEvents } from "./NotificationEvents"; import { baseMeta } from "./storybookUtils"; @@ -36,6 +37,15 @@ export const SMTPNotConfigured: Story = { }, } as DeploymentValues, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("link", { name: /View docs/ }), + ).toHaveAttribute( + "href", + docs("/admin/monitoring/notifications#smtp-email"), + ); + }, }; export const WebhookNotConfigured: Story = { @@ -53,6 +63,12 @@ export const WebhookNotConfigured: Story = { }, } as DeploymentValues, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("link", { name: /View docs/ }), + ).toHaveAttribute("href", docs("/admin/monitoring/notifications#webhook")); + }, }; export const ChangeMethod: Story = { diff --git a/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.tsx b/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.tsx index a0000ea57224b..e12199edae6a0 100644 --- a/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.tsx +++ b/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.tsx @@ -8,7 +8,7 @@ import { } from "#/api/queries/notifications"; import type { DeploymentValues } from "#/api/typesGenerated"; import { Alert } from "#/components/Alert/Alert"; -import { Button } from "#/components/Button/Button"; +import { Link } from "#/components/Link/Link"; import { Select, SelectContent, @@ -66,15 +66,14 @@ export const NotificationEvents: FC = ({ severity="warning" prominent actions={ - + + View docs + (opens in new tab) + } > Webhook notifications are enabled, but not properly configured. @@ -86,15 +85,14 @@ export const NotificationEvents: FC = ({ severity="warning" prominent actions={ - + + View docs + (opens in new tab) + } > SMTP notifications are enabled but not properly configured. diff --git a/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage.stories.tsx b/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage.stories.tsx index 4cc9b242df286..504edd2375c7a 100644 --- a/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { userEvent, within } from "storybook/test"; +import { expect, userEvent, within } from "storybook/test"; import { customNotificationTemplatesKey, notificationDispatchMethodsKey, @@ -10,6 +10,7 @@ import { MockNotificationMethodsResponse, MockSystemNotificationTemplates, } from "#/testHelpers/entities"; +import { docs } from "#/utils/docs"; import NotificationsPage from "./NotificationsPage"; import { baseMeta } from "./storybookUtils"; @@ -64,6 +65,20 @@ export const LoadingDispatchMethods: Story = { export const Events: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); + const docsLinks = canvas.getAllByRole("link", { name: /View docs/ }); + await expect(docsLinks).toHaveLength(3); + await expect(docsLinks[0]).toHaveAttribute( + "href", + docs("/admin/monitoring/notifications"), + ); + await expect(docsLinks[1]).toHaveAttribute( + "href", + docs("/admin/monitoring/notifications#webhook"), + ); + await expect(docsLinks[2]).toHaveAttribute( + "href", + docs("/admin/monitoring/notifications#smtp-email"), + ); // System notification templates await canvas.findByText("Template Events"); diff --git a/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage.tsx index 2214d8ac10610..55c492e557c94 100644 --- a/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage.tsx @@ -78,16 +78,14 @@ const NotificationsPage: FC = () => { <> Codestin Search App - + Notifications + + Control delivery methods for notifications on this deployment.{" "} - } - > - Notifications - - Control delivery methods for notifications on this deployment. diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx index 031b3fcc070a0..0c7a2c6666aff 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx @@ -167,7 +167,7 @@ export const AddApplicationIsScopedToApplicationsTab: Story = { // The docs link is tab-agnostic and stays put, which is what makes the // other one's disappearance a scoping decision rather than a quirk. - const docsLink = canvas.getByRole("link", { name: /read the docs/i }); + const docsLink = canvas.getByRole("link", { name: /view docs/i }); await expect(docsLink).toBeVisible(); await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); @@ -175,7 +175,7 @@ export const AddApplicationIsScopedToApplicationsTab: Story = { canvas.queryByRole("link", { name: "Add application" }), ).not.toBeInTheDocument(); await expect( - canvas.getByRole("link", { name: /read the docs/i }), + canvas.getByRole("link", { name: /view docs/i }), ).toBeVisible(); await userEvent.click(canvas.getByRole("tab", { name: "Applications" })); diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx index 13556be813045..91d948f1ea1ee 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx @@ -141,23 +141,10 @@ const OAuth2AppsSettingsPageView: FC = ({ return (
- {/* - * The header sits outside the tabs, so a tab-specific action here would - * promise to act on content it navigates away from. The docs link is - * tab-agnostic, so it stays on both. - */} - {canCreateApp && activeTab === "applications" && ( - - )} - - + canCreateApp && + activeTab === "applications" && } > OAuth2 applications @@ -168,7 +155,12 @@ const OAuth2AppsSettingsPageView: FC = ({ */} Register applications to use Coder as an OAuth2 provider - {settings && ", and configure how this deployment behaves as one"}. + {settings && ", and configure how this deployment behaves as one"}.{" "} + diff --git a/site/src/pages/DeploymentSettingsPage/ObservabilitySettingsPage/ObservabilitySettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/ObservabilitySettingsPage/ObservabilitySettingsPageView.stories.tsx index 87ef677ef320c..bc152e37bc6c9 100644 --- a/site/src/pages/DeploymentSettingsPage/ObservabilitySettingsPage/ObservabilitySettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/ObservabilitySettingsPage/ObservabilitySettingsPageView.stories.tsx @@ -82,9 +82,16 @@ export const OSS: Story = { await expect( canvas.getByRole("link", { name: "Start trial for free" }), ).toHaveAttribute("href", "/deployment/premium"); - await expect( - canvas.getByRole("link", { name: /Read the docs/ }), - ).toHaveAttribute("href", docs("/admin/security/audit-logs")); + const docsLinks = canvas.getAllByRole("link", { name: /View docs/ }); + await expect(docsLinks).toHaveLength(2); + await expect(docsLinks[0]).toHaveAttribute( + "href", + docs("/admin/security/audit-logs"), + ); + await expect(docsLinks[1]).toHaveAttribute( + "href", + docs("/admin/monitoring"), + ); await expect( canvas.queryByText("Audit Logs Retention"), ).not.toBeInTheDocument(); diff --git a/site/src/pages/DeploymentSettingsPage/ObservabilitySettingsPage/ObservabilitySettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/ObservabilitySettingsPage/ObservabilitySettingsPageView.tsx index 62b53185445d7..6c7f8a9ba0935 100644 --- a/site/src/pages/DeploymentSettingsPage/ObservabilitySettingsPage/ObservabilitySettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/ObservabilitySettingsPage/ObservabilitySettingsPageView.tsx @@ -23,11 +23,7 @@ export const ObservabilitySettingsPageView: FC< return (
- - } - > + Observability @@ -36,7 +32,11 @@ export const ObservabilitySettingsPageView: FC< Audit Logging - Allow auditors to monitor user operations in your deployment. + Allow auditors to monitor user operations in your deployment.{" "} + @@ -66,7 +66,11 @@ export const ObservabilitySettingsPageView: FC< Monitoring - Monitoring your Coder application with logs and metrics. + Monitoring your Coder application with logs and metrics.{" "} + diff --git a/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPageView.stories.tsx index f6f84b222c19c..d07aa4255ead5 100644 --- a/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPageView.stories.tsx @@ -1,5 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; import { MockDeploymentDAUResponse } from "#/testHelpers/entities"; +import { docs } from "#/utils/docs"; import { OverviewPageView } from "./OverviewPageView"; const meta: Meta = { @@ -44,7 +46,14 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Page: Story = {}; +export const Page: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("link", { name: /View docs/ }), + ).toHaveAttribute("href", docs("/admin/setup")); + }, +}; export const allExperimentsEnabled: Story = { args: { diff --git a/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPageView.tsx b/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPageView.tsx index 4a2c75fa4b466..87dfc033e5c79 100644 --- a/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPageView.tsx @@ -32,12 +32,11 @@ export const OverviewPageView: FC = ({ }) => { return ( <> - } - > + General - Information about your Coder deployment. + Information about your Coder deployment.{" "} + diff --git a/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPage.tsx b/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPage.tsx index cfef257a06e91..98a239b7df38d 100644 --- a/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPage.tsx @@ -49,17 +49,14 @@ const PremiumPage: FC = () => { <> Codestin Search App - - Review Coder system requirements - - } - > + Start a Coder trial For enterprises ready to achieve world-class security, scalability, - and developer experience. + and developer experience.{" "} + + Review Coder system requirements + = ({ Security - Ensure your Coder deployment is secure. + Ensure your Coder deployment is secure.{" "} + @@ -57,14 +61,12 @@ export const SecuritySettingsPageView: FC = ({
- - } - > - + + Browser-Only Connections{" "} {featureBrowserOnlyEnabled ? : } @@ -72,13 +74,14 @@ export const SecuritySettingsPageView: FC = ({ Block all workspace access via SSH, port forward, and other - non-browser connections. + non-browser connections.{" "} + - - {featureBrowserOnlyEnabled ? : } - {!isBrowserOnlyEntitled ? ( = { export default meta; type Story = StoryObj; -export const Page: Story = {}; +export const Page: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const docsLinks = canvas.getAllByRole("link", { name: /View docs/ }); + await expect(docsLinks).toHaveLength(2); + await expect(docsLinks[0]).toHaveAttribute( + "href", + docs("/admin/users/oidc-auth"), + ); + await expect(docsLinks[1]).toHaveAttribute( + "href", + docs("/admin/users/github-auth"), + ); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/UserAuthSettingsPage/UserAuthSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/UserAuthSettingsPage/UserAuthSettingsPageView.tsx index 1a5087d95cb4f..c460292d9c632 100644 --- a/site/src/pages/DeploymentSettingsPage/UserAuthSettingsPage/UserAuthSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/UserAuthSettingsPage/UserAuthSettingsPageView.tsx @@ -39,16 +39,16 @@ export const UserAuthSettingsPageView = ({ User Authentication - - } - > + Login with OpenID Connect - Set up authentication to login with OpenID Connect. + Set up authentication to login with OpenID Connect.{" "} + @@ -64,16 +64,16 @@ export const UserAuthSettingsPageView = ({
- - } - > + Login with GitHub - Set up authentication to login with GitHub. + Set up authentication to login with GitHub.{" "} + diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index fcf2eff29e423..cf0ff7fbef9a6 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -113,15 +113,12 @@ const GroupsPage: FC = () => {
{title} - - } - > + Groups Manage groups for this{" "} - {showOrganizations ? "organization" : "deployment"}. + {showOrganizations ? "organization" : "deployment"}.{" "} + diff --git a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.stories.tsx index 453db8c8acaf2..206e31f1b3fb2 100644 --- a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.stories.tsx @@ -36,7 +36,7 @@ export const NotEntitled: Story = { const canvas = within(canvasElement); await expect( - canvas.getByRole("link", { name: /Read the docs/ }), + canvas.getByRole("link", { name: /View docs/ }), ).toHaveAttribute("href", docs("/admin/users/organizations")); const cta = canvas.getByRole("link", { name: "Start trial for free" }); await expect(cta).toHaveAttribute("href", "/deployment/premium"); diff --git a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx index 5e17adcce6d53..76667e5cdecc1 100644 --- a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx @@ -76,9 +76,6 @@ export const CreateOrganizationPageView: FC< Go Back
-
- -
{Boolean(error) && !isApiValidationError(error) && ( @@ -97,7 +94,10 @@ export const CreateOrganizationPageView: FC<

New Organization

Organize your deployment into multiple platform teams with unique - provisioners, templates, groups, and members. + provisioners, templates, groups, and members.{" "} +

diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx index 611117319c401..059fe7cd8a420 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx @@ -82,14 +82,11 @@ const CustomRolesPage: FC = () => { - - } - > + Roles - Manage roles for this organization. + Manage roles for this organization.{" "} + diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx index 3fb537523c1e0..46511527dda74 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx @@ -123,15 +123,12 @@ const IdpSyncPage: FC = () => {
{title} - - } - > + IdP Sync Automatically assign groups or roles to a user based on their IdP - claims. + claims.{" "} + {!isIdpSyncEnabled ? ( diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPageView.stories.tsx index a7854060d3d82..2c573f2f45c61 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPageView.stories.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import type { ProvisionerJob } from "#/api/typesGenerated"; import { MockOrganization, MockProvisionerJob } from "#/testHelpers/entities"; +import { docs } from "#/utils/docs"; import { daysAgo } from "#/utils/time"; import OrganizationProvisionerJobsPageView from "./OrganizationProvisionerJobsPageView"; @@ -29,7 +30,17 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Default: Story = {}; +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("link", { name: /View docs/ }), + ).toHaveAttribute( + "href", + docs("/admin/provisioners/manage-provisioner-jobs"), + ); + }, +}; export const OrganizationNotFound: Story = { args: { diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPageView.tsx index 8d09ad1b6e305..42bd3437ea47c 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPageView.tsx @@ -110,7 +110,9 @@ const OrganizationProvisionerJobsPageView: FC< Provisioner Jobs are the individual tasks assigned to Provisioners when the workspaces are being built.{" "} - View docs + + View docs + diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.stories.tsx index ce34141fe9401..1b905046cf88d 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.stories.tsx @@ -101,7 +101,7 @@ export const Paywalled: 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("/admin/provisioners")); }, }; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx index da9be24980a93..4800dc1a1015e 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx @@ -51,12 +51,11 @@ export const OrganizationProvisionerKeysPageView: FC< return (
- } - > + Provisioner Keys - Manage provisioner keys used to authenticate provisioner instances. + Manage provisioner keys used to authenticate provisioner instances.{" "} + diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx index 340a75608bd7d..36f38e98d2a30 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx @@ -77,7 +77,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("/admin/provisioners")); }, }; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx index 916765ef8a8cd..8389ca33a7cde 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx @@ -61,13 +61,12 @@ export const OrganizationProvisionersPageView: FC< }) => { return (
- } - > + Provisioners Coder server runs provisioner daemons which execute terraform during - workspace and template builds. + workspace and template builds.{" "} + diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx index aaef36b28968c..99ad3485e07c3 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx @@ -41,16 +41,13 @@ const TemplatePermissionsPage: FC = () => { Codestin Search App
- + Permissions + + Manage which members and groups can use this template.{" "} - } - > - Permissions - - Manage which members and groups can use this template. diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx index 6c440a44365b6..d6c5c5688e094 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx @@ -109,7 +109,7 @@ export const Loaded: Story = { await expect(canvas.getByText("env var + file")).toBeInTheDocument(); await expect(canvas.getByText("not injected")).toBeInTheDocument(); - const docsLink = canvas.getByRole("link", { name: "Read the docs" }); + const docsLink = canvas.getByRole("link", { name: /View docs/ }); await expect(docsLink).toHaveAttribute( "href", expect.stringContaining("/user-guides/user-secrets"), diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx index cfac6530ab98f..5d356592e8ee6 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx @@ -1,4 +1,4 @@ -import { PlusIcon, SquareArrowOutUpRightIcon } from "lucide-react"; +import { PlusIcon } from "lucide-react"; import { type FC, useRef, useState } from "react"; import type { CreateUserSecretRequest, @@ -11,6 +11,7 @@ import { Button } from "#/components/Button/Button"; import { SettingsHeader, SettingsHeaderDescription, + SettingsHeaderDocsLink, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; import { docs } from "#/utils/docs"; @@ -86,29 +87,18 @@ export const SecretsPageView: FC = ({
- - -
+ } > Secrets Secrets with an environment variable or file path are injected into workspaces you own when they start. Each environment variable and file - path must be unique. + path must be unique.{" "} + diff --git a/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyView.stories.tsx b/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyView.stories.tsx index fd782de8a033c..f95f3269bb3d6 100644 --- a/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyView.stories.tsx +++ b/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyView.stories.tsx @@ -8,6 +8,7 @@ import { MockWorkspaceProxies, mockApiError, } from "#/testHelpers/entities"; +import { docs } from "#/utils/docs"; import { WorkspaceProxyView } from "./WorkspaceProxyView"; const meta: Meta = { @@ -30,6 +31,12 @@ export const PrimarySelected: Story = { proxyLatencies: MockProxyLatencies, preferredProxy: MockPrimaryWorkspaceProxy, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("link", { name: /View docs/ }), + ).toHaveAttribute("href", docs("/admin/networking/workspace-proxies")); + }, }; export const Example: Story = { diff --git a/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyView.tsx b/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyView.tsx index db6402b671788..e3636135008d9 100644 --- a/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyView.tsx +++ b/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyView.tsx @@ -46,17 +46,14 @@ export const WorkspaceProxyView: FC = ({ }) => { return (
- - } - > + Workspace Proxies Workspace proxies improve terminal and web app connections to - workspaces. + workspaces.{" "} + diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx index 6329d2f73cb98..b719afb1272b5 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx @@ -59,16 +59,13 @@ export const WorkspaceSharingPageView: FC = ({ }) => { return (
- + Sharing + + Share this workspace with other users and groups.{" "} - } - > - Sharing - - Share this workspace with other users and groups. From 634ebfd3e6b3d6d83a84f0c10751b63a5e609330 Mon Sep 17 00:00:00 2001 From: TJ Date: Wed, 26 Aug 2026 12:14:47 -0700 Subject: [PATCH 12/12] feat: reposition org pickers in AI settings models and MCP pages (#28564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repositions the organization picker on the AI settings models and MCP servers pages so it lives with the content it scopes instead of floating above the page header. **Models** (`/ai/settings/models`) - The picker moved out of `OrganizationModelsLayout` into a shared `ModelOrganizationSelect` component that preserves the current path and auxiliary query params when switching orgs. - List page: rendered in the filter row to the right of search. - Add/edit model form: rendered as row 3 of the form grid at 50% width, labeled "Organization". On the edit page it is informational only (a static value, not a picker), since switching org there would 404 the model. Also rendered in the no-provider fallback and "Provider not found" states so the switcher never disappears on those pages. **MCP servers** (`/ai/settings/mcp-servers`) - List page: new client-side search input (matches display name, slug, and URL) with the org picker beside it, label hidden. - Add/edit server form: the picker moved into the form as the third cell of the first row (slug, display name, organization), making it a 3-up. - `OrganizationPicker` now renders a static read-only value instead of a disabled button when the org cannot be changed (no handler or single org). This fixes the muted `content-disabled` text on the update page. The read-only rendering is shared with the models edit page via a new `OrganizationValue` component beside `OrganizationAutocomplete`. **Status columns** (both list tables) - The visible "Status" header and the Enabled/Disabled badges are removed; both list tables drop the status column entirely. Models whose provider is deleted or disabled show a warning "Unavailable" badge (with an explanatory tooltip) beside the name. - Disabled models and MCP servers show a "Disabled" badge beside the name, matching the existing "Default" badge placement, and the row dims like disabled providers: avatar/icon at half opacity, text in `content-disabled`.
Decision log - `ModelOrganizationSelect` reuses `OrganizationAutocomplete` and reads accessible organizations from the models context (`accessibleOrganizations` added to `OrganizationModelsContext`), rather than duplicating the layout's navigation logic per page. - Navigation semantics are unchanged: switching orgs rewrites the `org` search param and preserves the path and remaining params, exactly as the old layout-level picker did. - The MCP `OrganizationPicker` read-only state uses an `` element (labelable, keeps the `Label` association) rather than a disabled button, so non-interactive values do not render with disabled styling or sit in the tab order. - The add MCP server page keeps a standalone picker above the "cannot add servers" alert since the form (and its picker slot) is not rendered in that state. - Story review: one interaction story per new behavior. A `ModelsPageView` story duplicating the shared picker's select-and-navigate flow was deliberately dropped; list-page placement is covered by the `OrganizationModelsLayout` stories that mount the real `ModelsPage`. The `ModelForm` fallback-branch picker has no dedicated story since its sibling branch and the shared flow are covered.
Verification: `pnpm lint:types`, `pnpm check`, and all affected Storybook tests pass (ModelsPage and MCPServersPage: 140), plus the `organizationModels` and `mcpServerFormLogic` unit tests. --- 🤖 This PR was generated by Coder Agents on behalf of @tracyjohnsonux. --------- Co-authored-by: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> (cherry picked from commit 78c65ea556de627586a7e75314b98400db9705c8) --- docs/ai-coder/agents/models.md | 10 +- .../OrganizationAutocomplete.tsx | 129 +++++++++++++++++- .../AddMCPServerPageView.stories.tsx | 6 +- .../AddMCPServerPage/AddMCPServerPageView.tsx | 44 ++++-- .../MCPServersPage/MCPServersPage.stories.tsx | 68 ++++++--- .../MCPServersPage/MCPServersPage.tsx | 2 + .../MCPServersPageView.stories.tsx | 5 +- .../MCPServersPage/MCPServersPageView.tsx | 59 ++++++-- .../UpdateMCPServerPageView.stories.tsx | 24 ++-- .../UpdateMCPServerPageView.tsx | 16 ++- .../components/MCPServerForm.tsx | 11 +- .../components/MCPServerFormFields.tsx | 20 ++- .../components/MCPServerFormHeader.tsx | 95 ++++++------- .../components/MCPServerRow.tsx | 18 ++- .../components/OrganizationPicker.tsx | 58 ++------ .../AddModelPage/AddModelPageView.stories.tsx | 64 ++++++++- .../AddModelPage/AddModelPageView.tsx | 38 ++++++ .../ModelsPage/ModelsPageView.stories.tsx | 25 ++-- .../ModelsPage/ModelsPageView.tsx | 25 +++- .../OrganizationModelsLayout.stories.tsx | 28 +++- .../ModelsPage/OrganizationModelsLayout.tsx | 38 +----- .../UpdateModelPageView.stories.tsx | 13 ++ .../components/ModelForm.stories.tsx | 1 + .../ModelsPage/components/ModelForm.tsx | 36 ++++- .../ModelsPage/components/ModelFormFields.tsx | 51 ++++++- .../ModelFormProviderConfig.stories.tsx | 20 ++- .../components/ModelRow.stories.tsx | 57 ++++---- .../ModelsPage/components/ModelRow.tsx | 90 ++++++++---- .../ModelsPage/organizationModels.tsx | 26 ++++ 29 files changed, 782 insertions(+), 295 deletions(-) diff --git a/docs/ai-coder/agents/models.md b/docs/ai-coder/agents/models.md index 1eaefc880307a..a452c4df53f11 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 diff --git a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx index 92277ce1299ea..e36fa352611ae 100644 --- a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx +++ b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx @@ -12,6 +12,7 @@ import { CommandItem, CommandList, } from "#/components/Command/Command"; +import { Label } from "#/components/Label/Label"; import { Popover, PopoverContent, @@ -92,7 +93,7 @@ export const OrganizationAutocomplete: FC = ({ 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/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx index 66b3d67726a52..27a6acdde48d9 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 8582d61fb0eb8..5775f97cbef68 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 409526712a576..32579b03ece6d 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 b7fb6fe0f5872..d96f46a303400 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 f70664587795f..783eda2d8ed83 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 99f8aa0f99fc5..f7b54bf043c5c 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 29b4e357765b5..f79a4adc32c16 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 08444583a68d0..c3534d0aa4b37 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 0fb858b009481..dc43bcd2f230e 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 f88e4302773b1..a82518f70300c 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 924ab98fe2c7c..8cb589cae73dd 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, + ), + ); + }} + /> + )}