Follow-up to #28392. The `DateTimeRangePicker` popover opened with
`align="start"` (left-aligned to the trigger), while the other dropdowns
on the page use `align="end"`. This changes the `PopoverContent`
alignment to `align="end"` for consistency.
One-line change; no behavior or layout changes inside the dropdown
itself.
---
Generated by Coder Agents on behalf of @tracyjohnsonux.
Settings pages rendered the premium upsell banner at different vertical
offsets and the IdP sync pages used a lighter heading style than the
rest of the settings UI.
Three causes:
1. The IdP Sync (`/organizations/:org/idp-sync`) and Organization IdP
Sync (`/deployment/idp-org-sync`) pages hand-rolled their headers with a
bare `<h1 className="text-3xl m-0">` (missing `font-semibold`) and a
`flex flex-col gap-12` wrapper (48px above the banner).
2. Workspace Proxies and Appearance wrapped `SettingsHeader` in flex
columns with their own `gap` (`gap-4`, `gap-8`), which stacked on top of
the header's built-in `pb-6`, pushing the banner to 40px and 56px
offsets respectively while Groups sits at 24px.
3. `SettingsHeaderDescription` used `leading-normal` (21px lines) while
the inline `Link` component ("View docs") renders a 24px-tall
`inline-flex leading-6` box, inflating the line box on pages whose
description contains a docs link and shifting content below by ~3px.
Changes:
- Replace the custom header markup on both IdP sync pages with the
shared `SettingsHeader`, `SettingsHeaderTitle`, and
`SettingsHeaderDescription` components; the Export Policy button moves
into the `actions` slot.
- Hoist `SettingsHeader` out of the gap wrappers on Workspace Proxies
and Appearance so the header's `pb-6` is the single source of
header-to-content spacing; the gap now applies only within the entitled
content.
- Set `SettingsHeaderDescription` to `leading-6` so description line
height matches the inline `Link`, whether or not a docs link is present.
All affected pages now start content, including the premium paywall,
24px below the header description.
_Created by Coder Agents on behalf of @tracyjohnsonux._
Follow-up to #28392. Wires the new `DateTimeRangePicker` into the AI
Gateway sessions page, replacing the text-expression
`DateTimeRangeFilter`, and removes the redundant "Filters" preset
button.
- The picker trigger sits in the filter row alongside the search field
and the User/Provider/Client/Model dropdowns.
- The preset queries (All sessions, My sessions) behind the Filters
button duplicate the search field and the user dropdown, so the button
is dropped by passing no presets. Other pages using the shared `Filter`
component are unaffected.
- No API or URL contract changes: the filter query still stores resolved
`started_after`/`started_before` timestamps. The picker's preset id is
display-only component state, shown only while the URL range still
matches what the preset resolved to, so shared links keep rendering as
custom ranges.
- Clearing the search resets the picker to the default window, labeled
"Last 24 hours".
- The picker trigger chevron now uses the shared animated
`ChevronDownIcon`, rotating on open like the other dropdown triggers.
- The old `DateTimeRangeFilter` component is left in place (still
referenced by its stories); removing it can be a separate cleanup.
Storybook: the sessions filter story now asserts the Filters button is
gone and exercises opening the picker; existing stories updated to the
new value shape. `biome`, `tsc`, and the Storybook interaction tests for
the page pass.
---
Generated by Coder Agents on behalf of @tracyjohnsonux.
Adds a frontend-only `DateTimeRangePicker` component: a date range
picker with quick picks (Last 15 min, Last hour, Today, This week) and
per-boundary time selection. The dropdown opens as just the quick-pick
list; the calendar and From/To time fields only appear after selecting
"Custom range". Quick picks commit immediately, custom ranges commit via
Apply.
No backend changes. The value is always resolved dates:
```ts
interface DateTimeRangeValue {
start: Date;
end: Date;
preset?: string; // display metadata only, never sent to the backend
}
```
Quick picks resolve to concrete `start`/`end` at selection time;
`preset` exists solely so the trigger can keep rendering the preset
label. Consumers send `start`/`end` to the API in UTC via
`Date.toISOString()`.
Composed from existing primitives (`Calendar`, `Popover`, `Select`,
`Input`, `Button`); the `Calendar` component is unchanged. Includes 9
Storybook stories with play-function coverage: quick-picks-only open
state, immediate preset commit, custom panel expansion, apply flow,
invalid time validation, end-before-start validation, and cancel
discarding the draft.
<details>
<summary>Design notes and follow-ups</summary>
## Mockup deviations
- Added an Apply/Cancel footer to the custom panel. The design mockup
does not show one, but a draft range needs an explicit commit point.
- Trigger uses the shared `Button` outline variant to match the design
system.
## Settled decisions
- The API receives resolved timestamps, not preset tokens.
- All values sent to the backend are UTC; the component works in
browser-local time and conversion happens at the API boundary.
## Follow-ups (separate PRs)
- Wire the component into the pages that need it, with query-param
persistence where ranges should be shareable.
- The existing `DateTimeRangeFilter` (text-expression based, used by the
AIBridge sessions filter) is a candidate for migration to this design.
- Possible polish per design feedback: masked/segmented time input,
optional seconds, 24-hour locale support.
</details>
---
Generated by Coder Agents on behalf of @tracyjohnsonux.
The empty `MultiSelectCombobox` rendered ~46px tall next to the fixed
40px `Input`, breaking form rows that place them side by side, most
visibly the IdP sync mapping fields (`/organizations/{org}/idp-sync`,
IdP org sync, template/parameter forms).
## Root cause
#27821 removed MUI's `CssBaseline` and enabled Tailwind preflight
(`corePlugins.preflight` was previously `false`). Preflight sets `input
{ line-height: inherit }`, so the combobox's inner cmdk input started
inheriting the wrapper's `text-sm` 24px line instead of the browser
default. With its `py-2.5` padding the empty combobox computed to 24 +
20 + 2 = 46px, while `Input` stays pinned at `h-10`.
This is a sibling of the `ModelSelector` regressions fixed in #28399,
but in the shared combobox primitive, so that fix could not reach these
pages.
## Fix
- Pin the inner cmdk input to `leading-5` and reduce the empty-state
padding to `py-2` (20 + 16 = 36px row).
- Make the wrapper a `flex flex-col justify-center` so the row centers
within `min-h-10`, giving exactly 40px when empty while the selected
state still grows with its badges.
## Validation
- Red-green verified with a temporary geometry probe story: combobox
wrapper 46px before, 40px after (Input 40px in both). The probe was not
committed (FE10) and no new story ships with this PR per review.
- `pnpm test:storybook` for `MultiSelectCombobox`, `IdpSyncPageView`,
`IdpOrgSyncPageView`, and `DynamicParameter`: all pass.
- `biome check` and `tsc -p .` clean.
> 🤖 This PR was written by Coder Agents on behalf of @tracyjohnsonux.
Adds an organization picker to the MCP server settings pages so
multi-org deployments can manage every organization's MCP servers
through the UI, not just the default organization's. Closes CODAGT-714
(scoped to MCP servers only; model configs stay deployment-wide until
CODAGT-709 lands).
## Stack Context
Top of the CODAGT-711/717/712/806 stack: MCP server configs are
org-scoped with RBAC, audit logging, per-config ACLs, and owner-scoped
user tokens. This layer makes the admin UI org-aware, resolving the
"default org only" limitation documented in the bottom PR.
## Summary
- The MCP servers list and add pages resolve the working organization
from an `org` search param (falling back to the default org), and render
an organization picker built on the existing `OrganizationAutocomplete`.
The picker only appears when more than one organization is visible, so
single-org deployments see no change.
- Selecting an organization rewrites the search param, refetches the
list for that org, and threads through to the add page so creation posts
to the selected organization.
- The update page now loads the server by ID (new organization-scoped
`GET /api/experimental/organizations/{organization}/mcp-servers/{id}`
frontend binding) instead of listing the default org and searching
client-side, so servers from any organization can be edited. Post-save
navigation returns to the owning organization's list via the server's
`organization_id`.
- Removes the now-unused `getDefaultOrganizationId` helper.
- Review hardening: organization display labels are disambiguated with
the unique org name when effective display names collide (picker
options, trigger text, and accessible name). Access is no longer
restricted to deployment admins: the pages, sidebar item, and nav entry
accept org-scoped `mcp_server_config` permissions, the picker lists only
organizations the user can view, content and affordances (add, edit,
delete) are gated on the selected organization's permissions, and
deployment admins keep full access everywhere.
## Testing
Storybook interaction stories cover: picker hidden with one org,
switching orgs refetches and swaps list contents, add posting to the
URL-selected org, add posting after an in-form org switch (asserting
typed form state survives the switch), and update loading by ID.
Red-green verified by breaking the search-param resolution and observing
the new stories fail.
> Mux acted on Mike's behalf for this PR.
A text-expression datetime range picker with From/To inputs accepting
`now`, a clock time (current day), a date (midnight), or a date with a
clock time. Invalid text gets inline errors, underspecified expressions
resolve on blur, and out-of-order boundaries clamp against the other
one. The trigger derives a concise label ("Last 24 hours", "Apr 10",
"Aug 11 - Today", "Apr 17 - 19").
Placed in `site/src/components/DateTimeRangeFilter/` so other pages can
reuse it. Expression parsing and trigger-label derivation live in
`timeRange.ts` next to the component; both are generic over `Date`
pairs. Depends on #28254 for the shared `filterQuery` serialization
helpers.
Part of
[AIGOV-580](https://linear.app/codercom/issue/AIGOV-580/ai-gateway-sessions-page-takes-5-10-seconds-to-load)
---
_Generated by Coder Agents on behalf of @johnstcn._$
---
**Stack:** #28254 (filterQuery fix) \u2192 #28255 (component) \u2192
#28256 (sessions page)
`stringifyFilter` in the shared `Filter` component only quoted values
containing spaces. Filter values like RFC 3339 timestamps
(`2026-08-16T20:42:00Z`) contain colons but no spaces, so when any
filter was edited and the whole query re-serialized, the timestamp went
out unquoted and the backend `searchTerms` parser rejected it (`Query
element ... can only contain 1 ':'`).
Quote values containing colons as well; they were never valid unquoted
because the backend parser already rejects them. Extract
`parseFilterQuery`/`stringifyFilter` into `filterQuery.ts` with unit
tests, including a round-trip of quoted timestamps.
Part of
[AIGOV-580](https://linear.app/codercom/issue/AIGOV-580/ai-gateway-sessions-page-takes-5-10-seconds-to-load)
---
_Generated by Coder Agents on behalf of @johnstcn._$
---
**Stack:** #28254 (filterQuery fix) \u2192 #28255 (component) \u2192
#28256 (sessions page)
## Summary
Deflakes the `adjust user theme preference` Playwright test
(`site/e2e/tests/users/userSettings.spec.ts`), tracked in DEVEX-415.
The test selected the Light theme and then hard-navigated with
`page.goto`
before the optimistic appearance update was persisted. The navigation
could
cancel the in-flight `PUT /api/v2/users/me/appearance`, so the reloaded
document embedded the stale `dark` preference and the final assertion
flaked.
`toPass` retries could not help because retrying the reload only
re-reads the
still-stale persisted state.
## Fix
Wait for the appearance form's save spinner to clear before the hard
reload,
mirroring how other settings tests wait for a visible save confirmation.
Asserting the optimistic light class first guarantees the spinner is
already
showing if a save started; a repeat run that is already light never
shows it,
so the test is idempotent and there are no direct API calls.
To give the test a UI signal, `Spinner` gets an opt-in `label` prop that
exposes it as a `role="status"` live region with an `aria-label`
(decorative
otherwise). `Loader` moves its label onto its own status container so it
keeps
a single status region.
## Changes
- `site/src/components/Spinner/Spinner.tsx`: opt-in `label` prop.
- `site/src/components/Loader/Loader.tsx`: label on its own status
region.
- `site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx`:
label
both appearance save spinners.
- `site/e2e/tests/users/userSettings.spec.ts`: wait for the save
spinner.
## Validation
- `biome check` and `tsc -p .` pass.
- Loader + AppearancePage unit tests and the affected storybook tests
pass.
- The e2e test passed 20/20 under
`pnpm playwright:test -g "adjust user theme preference" --repeat-each
20`.
<details>
<summary>Implementation plan & decision log</summary>
# DEVEX-415: Fix flake in "adjust user theme preference" e2e test
## Problem
Playwright test `site/e2e/tests/users/userSettings.spec.ts` → `adjust
user
theme preference` flakes. After selecting the Light theme and
hard-navigating
to `/`, the reloaded page sometimes stays `dark`, failing the final
assertion.
CI Flake Bot has recorded repeated recurrences on `main` (latest
2026-08-17,
runs `32004239187`, `31745261984`) even after PR #25183 added `toPass`
retries.
## Root cause (confirmed by reading the code)
The appearance update is optimistic and its persistence is not awaited
before
navigation:
- `updateAppearanceSettings` (`site/src/api/queries/users.ts`) has an
`onMutate` that optimistically writes the new theme into the React Query
cache. The `<html>` class flips to `light` immediately, before the
`PUT /api/v2/users/me/appearance` completes.
- `useQueuedAppearanceSubmit`
(`site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.tsx`)
serializes submits: if a request is in flight, the next is queued and
only
fires after the first settles.
- A fresh `member` user has **empty** appearance settings.
`migrateLegacyPreference` (`site/src/theme/themeMode.ts`) maps empty
settings
to `{ mode: "single", theme: "dark" }` (`DEFAULT_THEME = "dark"`). So
the
"Theme mode" dropdown already starts on **Single theme** and `<html>`
starts
`dark`. Selecting "Single theme" in the test is therefore a **no-op**
(`onChangeMode` early-returns when `mode === draft.mode`) and fires
**no**
PUT. The only appearance PUT in the test is the one from clicking
"Light default" (`onSelectSingle("light")` → `theme_preference:
"light"`).
- The test's first `expectLightThemeClasses(page)` passes purely from
the
optimistic cache. It then calls `page.goto("/")` almost immediately
(~20 ms after the click). If PUT #2 (the light one) has not persisted
server-side, the new document loads the still-persisted `dark`
preference
from embedded metadata, and every retry of the post-navigation assertion
sees `dark` for the full 10 s window.
`toPass` cannot help post-navigation because it only re-reads stale,
already
persisted state; it cannot make an unfinished/queued PUT complete.
## Implemented fix: wait for the saving spinner (UI signal) before
navigation
> Iteration history: (1) An earlier attempt waited on the appearance
`PUT` via
> a `waitForApiCall` helper, but that couples the test to a state
transition
> and is non-idempotent (a repeat run that is already light fires no
PUT, so
> the wait times out). CI retries reuse the same ephemeral server, so
this is a
> real hazard. (2) A second attempt confirmed persistence with
> `page.request.get(...)`, but that calls the API directly and stops
being a
> site test. Both were rejected.
The repo's non-flaky settings tests wait for a **visible save
confirmation**
(e.g. "settings updated successfully" toasts) before trusting the
result. The
appearance theme form has no toast; its only save-in-progress feedback
is the
`<Spinner>`. So the fix mirrors that pattern using the spinner:
1. Make the theme section's spinner identifiable via a new opt-in
`label` prop
on `Spinner` (sets `role="status"` + `aria-label` only when provided;
decorative otherwise). `Loader` moves its label onto its own `status`
container so it keeps a single status region and its `getByLabelText`
queries keep working.
2. In the test, after clicking "Light default", assert the optimistic
light
class, then wait for that spinner to be hidden before `page.goto("/")`.
Why this is correct and idempotent:
- React Query sets `isPending` before `onMutate` applies the optimistic
cache
update, so by the time the optimistic light class is visible the spinner
is
already showing if a save started. Waiting for it to clear guarantees
the PUT
settled (and was not canceled by navigation) before the reload.
- A repeat run that is already light: clicking "Light default" is a
no-op radio
change, no PUT fires, the spinner never shows, and `toBeHidden` passes
immediately. The reload still shows light.
- No direct API calls: the test only observes site UI.
### Implemented changes
`Spinner` gains an opt-in `label` prop
(`site/src/components/Spinner/Spinner.tsx`):
```tsx
role={label ? "status" : undefined}
aria-label={label}
```
`Loader` carries the label on its own status container
(`site/src/components/Loader/Loader.tsx`), and the appearance save
spinners use
the new prop (`AppearanceForm.tsx`):
```tsx
<Spinner loading={isUpdating} size="sm" label="Saving theme preference" />
<Spinner loading={isUpdating} size="sm" label="Saving terminal font" />
```
`site/e2e/tests/users/userSettings.spec.ts`:
```ts
await expect(
page.getByRole("combobox", { name: /theme mode/i }),
).toContainText("Single theme"); // precondition: single mode
const singleThemeGroup = page.getByRole("group", { name: "Theme" });
await expect(singleThemeGroup).toBeVisible();
await singleThemeGroup.getByText("Light default", { exact: true }).click();
await expectLightThemeClasses(page); // optimistic DOM => spinner showing if saving
await expect(
page.getByRole("status", { name: "Saving theme preference" }),
).toBeHidden(); // save settled before the hard reload
await page.goto("/", { waitUntil: "domcontentloaded" });
await expectLightThemeClasses(page);
```
Validation: `biome check`, `tsc -p .` pass; Loader + AppearancePage unit
tests
and the affected storybook tests pass; the e2e test passed 20/20 under
`pnpm playwright:test -g "adjust user theme preference" --repeat-each
20`
(idempotent).
## Alternatives considered
- **Wait on the appearance `PUT` via a `waitForApiCall` helper**:
rejected. It
couples the test to a state transition and is non-idempotent, a repeat
run
that is already light fires no PUT so the wait times out (14/15 repeats
failed). CI retries reuse the same ephemeral server, so this is a real
hazard, not just a local-repeat artifact.
- **Confirm persistence with `page.request.get(...)`**: rejected. It
calls the
API directly and stops being a site test.
- **Default `role="status"` on the shared `Spinner`**: rejected.
`Loader` wraps
`Spinner` in its own `status` div, so a default would nest two status
regions and break `Loader.test.tsx`. The opt-in `label` prop avoids
this.
- **Add a networkidle wait or sleep before navigation**: rejected.
Violates
the repo guidance against `time.Sleep`-style timing hacks and is
inherently
racy.
- **Fix product behavior instead of the test**: out of scope. Related
bug
DEVEX-94 ("Light Theme setting not respected until Appearance page
opened")
tracks product-side persistence/embedding behavior; this task is scoped
to
stabilizing the e2e test.
## Files touched
- `site/src/components/Spinner/Spinner.tsx` (new opt-in `label` prop).
- `site/src/components/Loader/Loader.tsx` (label on its own status
region).
- `site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx`
(use the
`label` prop on both appearance save spinners).
- `site/e2e/tests/users/userSettings.spec.ts` (wait for the spinner).
</details>
---
This PR was created by Coder Agents on behalf of @jeremyruppel.
---------
Co-authored-by: Samuel Volin <sam.volin@coder.com>
> 🤖 This PR was modified by Coder Agents on behalf of Jake Howell.
Until we meet again.
## Stack
- #27636
- #27718
- #27719
- #27722
- #27723
- #27724
- #27728
- #27730
- #27732
- #27762
- #27763
- #27786
- #27787
- #27788
- #27789
- #27790
- #27791
- #27817
- #27820
- #28009
## Final removal (`c39b664`)
Removes the last of MUI and Emotion now that every surface has been
migrated:
- **Dependencies**: drops `@mui/material` and
`@emotion/{cache,css,react,styled}` from `package.json` /
`pnpm-lock.yaml`, and deletes the `@types/emotion.d.ts` and
`@types/mui.d.ts` module augmentations.
- **Theming**: replaces the Emotion `CacheProvider`, MUI `ThemeProvider`
/ `StyledEngineProvider`, and `CssBaseline` in `ThemeProvider` with a
lightweight `theme/context.tsx` that exposes `ThemeContextProvider` and
a `useTheme` hook.
- **Global styles**: moves the base `body` styles (background, text
color, font, antialiasing) that `CssBaseline` previously provided into
`index.css`, and drops the temporary MUI modal/popover scrollbar-gutter
workaround.
- **Cleanup**: removes the MUI → shadcn / Emotion → Tailwind migration
guidance from `site/AGENTS.md`, updates the Storybook `preview.tsx`, and
adjusts assorted components (`Command`, `Slider`, `Switch`, `Tabs`,
`SyntaxHighlighter`, timing charts) and theme files to consume the new
context instead of MUI/Emotion.
> 🤖 This PR was written by Coder Agents on behalf of Jake Howell.
Follow-up to #27798. That PR introduced a shared `Drawer` primitive by
adding [`vaul`](https://github.com/emilkowalski/vaul) as a dependency.
As raised in review, `vaul` is currently unmaintained, and this drawer
is the only place in the UI using that concept.
This PR takes the
[suggested](https://github.com/coder/coder/pull/27798#pullrequestreview-4901095865)
route: a dead-simple, self-owned drawer that covers this one case well,
built directly on Radix UI's `Dialog` (already a dependency via
`radix-ui`) instead of `vaul`. It keeps the same shadcn-style API
(`Drawer`, `DrawerTrigger`, `DrawerContent`, `DrawerHeader`,
`DrawerFooter`, `DrawerTitle`, `DrawerDescription`, `DrawerClose`) so
usage stays familiar, and migrates `CreateTemplatePage`'s
`BuildLogsDrawer` off MUI onto it.
### What changed
- Add `site/src/components/Drawer/Drawer.tsx`: a reusable drawer/sheet
built on `radix-ui` `Dialog`. Supports a `direction` prop
(`top`/`bottom`/`left`/`right`, default `right`) with slide animations
via `tailwindcss-animate`. No new dependencies.
- Migrate `BuildLogsDrawer` from `@mui/material/Drawer` to the new
component. Desktop panel stays at 800px via `min(800px, 100%)` so it
stays within the viewport on mobile.
- Storybook coverage with `play()` functions for both the generic
`Drawer` (open/close, direction) and `BuildLogsDrawer` (close via the X
button and via Escape assert `onClose`), addressing the earlier P1
review note about covering the controlled close path.
### Why Radix instead of vaul
- `radix-ui` `Dialog` is already a dependency and provides
accessibility, focus management, and open/close state.
- No dependency on an unmaintained package for a single-use concept.
- `vaul`'s drag-to-dismiss gesture is not needed for this build-logs use
case.
Repeat of #27604, which I had mistakenly manually merged into #27603,
instead of letting Graphite handle it after #27603 merges into main.
Reattempting with GitHub stacks now instead of Graphite!
See #27604 for details/review.
> 🤖 This PR was modified by Coder Agents on behalf of Jake Howell.
Replace MUI `TextField` / Emotion theming in `IconField` with
`InputGroup`, `Popover`, and Tailwind.
`IconPickerField` already had the demui'd `InputGroup` + `Popover`
implementation, so that was ported into the shared `IconField`. Per
review feedback, `IconPickerField` is now removed and its call sites
(the MCP server, provider, and OAuth app forms) use `IconField`
directly.
> 🤖 This PR was written by Coder Agents on behalf of Jake Howell.
## Summary
Deletes the shared `Pill` component (`site/src/components/Pill/`) and
migrates every consumer to `Badge`. Accepts the intentional visual shift
(`rounded-full` → `rounded-md`, slightly different color tokens). No
`ThemeRole` variants were added to `Badge`.
### Key changes
- Added `site/src/components/Badge/themeRoleToBadgeVariant.ts`, a small
mapper used at call sites that receive a `ThemeRole` (e.g.
`StatusBadge`, `TemplateVersionStatusBadge`).
- Exported `BadgeProps` from `Badge.tsx` so the mapper and status
helpers can reference the variant type.
- Replaced `PillSpinner` with `<Spinner loading />`.
- Renamed `StatusPill` → `StatusBadge` (folder, file, and export) and
updated the audit log and connection log imports.
- Extended `Badge.stories.tsx` with a status + spinner icon pattern
(rather than porting the full Pill story).
### Variant mapping applied
| Pill `type` | Badge `variant` |
|---|---|
| `success` | `green` |
| `error` | `destructive` |
| `warning`, `danger` | `warning` |
| `active`, `notice` | `info` |
| `inactive`, `muted`, `info` | `default` |
| `preview` | `purple` |
### Out of scope
The local Health-page `Pill` in `site/src/pages/HealthPage/Content.tsx`
is a separate component with no shared import; it will be migrated in a
follow-up.
## Validation
- `pnpm check` (biome) passes
- `pnpm lint` (biome + tsc + circular-deps + compiler + knip) passes
- `pnpm test:storybook` for Badge, TemplatePageHeader, and AuditLogRow
stories: 30/30 pass
- Grep for `components/Pill` and `PillSpinner` in `site/src` returns
nothing
<details>
<summary>Implementation plan</summary>
# Migrate Pill to Badge
## Goal
Delete `site/src/components/Pill/Pill.tsx` and migrate every shared-Pill
consumer to `site/src/components/Badge/Badge.tsx`. Accept the visual
shift (`rounded-full` → `rounded-md`, slightly different color tokens).
Do not add ThemeRole variants to Badge.
**Out of scope:** the local Health-page `Pill` in
`site/src/pages/HealthPage/Content.tsx` (separate component, no shared
import). Rename/migrate that in a follow-up.
## Variant mapping
Use this fixed mapping at every call site (Pill `type` → Badge
`variant`):
| Pill `type` | Badge `variant` | Notes |
|---|---|---|
| `success` | `green` | |
| `error` | `destructive` | |
| `warning`, `danger` | `warning` | Pill treats danger like orange
warning |
| `active`, `notice` | `info` | sky / pending |
| `inactive`, `muted`, `info` | `default` | Pill `info` is neutral grey;
Badge `info` is sky, so map Pill `info` to `default` |
| `preview` | `purple` | |
Other remaps:
- `icon={node}` → put `node` as the first child of `Badge`
- `size="lg"` → `size="md"` (closest); add a local `className` only if a
specific layout breaks
- `PillSpinner` → `<Spinner loading />` from
`site/src/components/Spinner/Spinner.tsx`
- Prefer existing wrappers when they already exist (e.g.
`DeprecatedBadge` on the template header)
For ThemeRole-driven helpers (`httpStatusColor`, status getters), add a
tiny shared mapper next to Badge
(`site/src/components/Badge/themeRoleToBadgeVariant.ts`). Use it in
`StatusPill` and any site that still receives `ThemeRole` rather than
hardcoding variants twice.
## Call-site work (ordered easy → hard)
1. **Trivial label chips** (import swap + variant map):
TemplatePageHeader (use `DeprecatedBadge`), VersionRow,
ChangeWorkspaceVersionDialog, PermissionPillsList, IdpPillList,
OrganizationPills
2. **Icon-as-child chips**: Provisioner, ProvisionerTag (local
`BooleanPill` becomes a Badge wrapper), TemplateExampleCard (keep
clickable styles via `className`)
3. **Status + spinner**: TemplateVersionStatusBadge (Badge + children
icons; `PillSpinner` → `Spinner`), utils/workspace.tsx (replace
`PillSpinner` imports only)
4. **Wrappers that own ThemeRole**: StatusPill (switch to Badge +
`themeRoleToBadgeVariant(httpStatusColor(...))`; rename to
`StatusBadge`), Notifications (Badge with severity `className`
overrides)
5. **Delete shared Pill**: remove `Pill.tsx`, `Pill.stories.tsx`, and
the `components/Pill` folder; extend `Badge.stories.tsx` only if a
migrated pattern is missing (icon + spinner status)
## Non-goals / constraints
- Do not change Badge's default shape or add `rounded-full`
- Do not broaden Badge's public API with `type` / `icon` props (that
recreates Pill)
- Do not rename IDP/permission "PillsList" filenames unless needed for
imports
- Modify Badge itself only for the new mapper helper (or stories)
## Validation
- `pnpm check` / `pnpm lint` under `site/`
- Storybook smoke on Badge + affected pages
- Grep for `components/Pill` and `PillSpinner` must be empty in
`site/src`
</details>
Migrates the New workspace template picker off MUI Link, Emotion `css`,
and the custom Popover/MenuSearch stack onto the shared Combobox used by
filters.
Selecting a template still navigates to create a workspace. The list
max-height uses the popover's available height so the footer stays
visible and scrolling works on short viewports.
Fixes several Storybook play function failures that show up in Pixel
(and some that fail under `vitest --project=storybook`).
### Component / behavior
- **DurationField**: restore digit-only filtering so non-numeric input
is stripped again. A demui pass had stopped filtering and relied on
`pattern`, which does not prevent typing.
### Pixel viewport mismatches
Pixel ignores Storybook viewport params. Shared helpers
`pixelWithDesktop` / `pixelWithPhone` live in `testHelpers/pixel.ts`.
- **NavbarView**: admin dropdown stories are desktop-only. Pixel tablet
is 744px, below Tailwind `md` (768px), so Admin settings is hidden and
the play click fails. `MobileMenu` already covers the collapsed navbar.
- **AgentPageHeader**: mobile meatball menu story is phone-only. At
laptop width `sm:hidden` keeps "More options" out of the a11y tree even
when `matchMedia` is mocked as mobile.
### Story assertion fixes
- **WorkspaceSettingsPageView**: Formik passes helpers as a second
`onSubmit` argument; the assertion now allows that.
- **AgentChatPageView**: sidebar tab persistence stories use Git instead
of Terminal. Clicking Terminal mounts xterm, which throws an unhandled
`dimensions` error and fails the run even when tab assertions pass.
- **NetworkCallsTable / Blocked Badge**: badge copy is split across an
`sr-only` span and the count; assertion matches combined `textContent`.
- **CreateOAuth2AppPageView / Default**: wait for Formik
validate-on-mount before asserting the submit button is disabled.
- **NotificationEvents / Change Method** (+ Error): combobox accessible
name is now `Notification method for ${template}`; match with a regex.
- **DynamicClientRegistrationSetting / Keeps Focus While Updating**:
wait for the Enable label after the harness finishes the request.
Pixel logs play failures but still exits 0, so these can stay green in
CI while failing in the Pixel report.
---------
Co-authored-by: Danielle Maywood <danielle@themaywoods.com>
> 🤖 This PR was modified by Coder Agents on behalf of Jake Howell.
Continue the MUI → Tailwind/shadcn migration across a few auth and
settings surfaces.
- Create token form: MUI `TextField`/`MenuItem` → `FormField`, `Select`,
and `Input` (drops Emotion for section min-width)
- Create organization form: `TextField` → `FormField` / `Textarea`,
matching the organization info form
- SSO security section and external auth: MUI `Link`/`TextField` →
shared `Link`, `Input`, and `Label`
- Permission and IdP pill lists: MUI `Stack` → `flex flex-row gap-2`
- Minor link layout polish on Git device auth / external auth pages
---
_Also removes the now-unused `@emotion/css` dependency from
`site/package.json` (and the lockfile), which was the last usage of it.
This fixes the `knip` CI lint failure ("Unused dependencies:
@emotion/css")._
> 🤖 This PR was written by Coder Agents on behalf of Jake Howell.
Removes the remaining MUI/Emotion usage from the Template Settings
pages, moving them over to Tailwind and the shared component primitives.
## What
- **`<TemplateScheduleForm />`** — deMUI'd, along with the shared
**`<DurationField />`** it relies on.
- **`<TemplateVariablesForm />` / `<TemplateVariableField />`** —
deMUI'd.
- **`<TemplateSettingsForm />`** (general settings) — deMUI'd.
No behavioural changes intended; this is a styling/plumbing refactor.
`SelectMenu` was only referenced by its Storybook story. Nothing in the
app consumed it.
Delete the component and story so we drop the unused MUI `MenuItem` /
`MenuList` wrapper and avoid treating dead demui code as a shared
primitive.
Adds the admin-controlled OAuth2 Dynamic Client Registration setting
landed by #27316 (`GET`/`PUT /api/v2/oauth2-provider/settings`) to the
OAuth2 Applications deployment settings page, since it was previously
only reachable via the API or `coder oauth2-provider dcr
enable|disable`.
The page is now tabbed, **Applications** and **Settings**, so DCR has a
home that further OAuth2 settings can share (an Initial Access Token
setting is a likely next one). The active tab is backed by a `tab`
search param, so `?tab=settings` links straight to it, and an
unpermitted deep link falls back to **Applications** rather than
selecting nothing. On the Settings tab, DCR renders as a titled section
with a description, an `Enabled` badge when active, and an
Enable/Disable button.
Enabling opens a confirmation dialog, since it lets any OAuth2 client
self-register against the deployment without prior admin approval (RFC
7591). Disabling is immediate, no confirmation.
The control is a button rather than a switch on design feedback: a
switch reads as an immediate on/off flip, which conflicts with a
confirmation dialog standing in front of it, and it left the only
explanation of the risk inside a dialog that disappears. A button
carries the confirmation step without misrepresenting what a click
costs, the always-visible description explains the setting on the page,
and the `Enabled` badge gives the active state a persistent indicator.
The layout follows Tracy's mockup on `tj/oauth2-apps-pagination`; the
apps-table pagination work that shares that branch is deliberately not
included here.
Visibility and editability are gated on the same
`ResourceDeploymentConfig` RBAC checks the endpoint itself enforces
(`viewDeploymentConfig` / `editDeploymentConfig`), not a separate
hardcoded check. The view takes the settings values as one optional
`settings` prop, absent when the viewer lacks `viewDeploymentConfig`, so
"cannot view" is the shape of the prop rather than a flag the caller
keeps consistent with the values beside it, and the tab is not rendered
at all.
Closes https://github.com/coder/coder/issues/27432
## Where this sits in the request path
```mermaid
sequenceDiagram
autonumber
actor Admin
participant View as OAuth2AppsSettingsPageView<br/>(Tabs + Enable/Disable + Dialog)
participant Page as OAuth2AppsSettingsPage<br/>(React Query)
participant S as coderd
Note over Page: On mount
Page->>S: GET /api/v2/oauth2-provider/settings
S-->>Page: { dynamic_client_registration_enabled }
Page-->>View: settings: { dynamicClientRegistrationEnabled, canEdit, ... }
Note over Admin,View: Admin opens the Settings tab and enables DCR
Admin->>View: click "Enable"
View->>View: open confirmation dialog<br/>(no request sent yet)
Admin->>View: click Confirm
View->>Page: settings.onDynamicClientRegistrationChange(true)
Page->>S: PUT /api/v2/oauth2-provider/settings<br/>{dynamic_client_registration_enabled: true}
S-->>Page: 200 OK (audited)
Page->>S: GET /api/v2/oauth2-provider/settings (refetch)
S-->>Page: { dynamic_client_registration_enabled: true }
Page-->>View: section shows the "Enabled" badge and a Disable button
Note over Admin,View: Admin disables DCR
Admin->>View: click "Disable"
View->>Page: onDynamicClientRegistrationChange(false)<br/>(no dialog, disable is immediate)
Page->>S: PUT ... {dynamic_client_registration_enabled: false}
S-->>Page: 200 OK (audited)
```
## Files changed
All 10 files are hand-written; nothing in this PR is `make gen` output.
| File | What changed |
|---|---|
| `site/src/api/api.ts` | New
`getOAuth2ProviderSettings`/`putOAuth2ProviderSettings` methods, thin
typed wrappers around the two endpoints #27316 added to `main`. |
| `site/src/api/api.test.ts` | Covers both methods against the request
they issue and the error they propagate. |
| `site/src/api/queries/oauth2.ts` | A `getSettings` query and a
`putSettings` mutation that invalidates the settings key on success.
Both the app and settings keys now derive from a shared
`oauth2ProviderKey` constant. |
| `site/src/api/queries/oauth2.test.ts` | 4 tests: the key nesting, both
delegations, and that a successful update invalidates the settings key
without touching app queries. |
| `.../OAuth2AppsSettingsPage.tsx` | Wires query and mutation into the
page and passes the settings values down as one object, or omits it
entirely without `viewDeploymentConfig`. The apps error stays its own
prop, since the view gates the applications empty state on it. |
| `.../OAuth2AppsSettingsPageView.tsx` | `Tabs` splitting Applications
from Settings. The settings tab distinguishes loading, failed, and a
value the server omitted rather than rendering nothing, and the header's
"Add application" action is scoped to the applications tab. |
| `.../OAuth2AppsSettingsPageView.stories.tsx` | 14 stories, covering
the tab wiring, both permission boundaries, the header action's scope,
and the settings tab's loading, fetch-error, update-error, and
value-omitted states. |
| `.../DynamicClientRegistrationSetting.tsx` | The section itself:
heading, description including what disabling does not undo, `Enabled`
badge, a permission explanation when the viewer cannot edit, and one
button that confirms only in the enable direction. |
| `.../DynamicClientRegistrationSetting.stories.tsx` | 11 stories,
including focus surviving an in-flight request and the dialog ignoring a
value that changes underneath it. |
| `docs/admin/integrations/oauth2-provider.md` | Adds the web UI route
to the DCR section, which previously enumerated only the CLI and the
management API. |
## Suggested review order
Follows the direction data actually flows, from the raw HTTP call up to
the rendered section.
1. **`site/src/api/api.ts`**: the two new methods. Confirms they match
the `codersdk.OAuth2ProviderSettings` shape #27316 landed and sit next
to the existing OAuth2 app methods they mirror.
2. **`site/src/api/queries/oauth2.ts`**: the query/mutation pair. The
mutation's `onSuccess` → `invalidateQueries` is the one detail worth
double-checking: it's what makes the on-screen state catch up with what
was just saved, rather than trusting the PUT payload.
3. **`OAuth2AppsSettingsPage.tsx`**: the container. Check the two
separate permission gates (`viewDeploymentConfig` on the query's
`enabled` option, `editDeploymentConfig` on the button's editability)
match the RBAC the backend enforces.
4. **`OAuth2AppsSettingsPageView.tsx`**: the tabs and the settings tab's
four states. The `settings` prop being optional is what hides the tab;
the error inside the tab is deliberately separate from the page-level
`error`, which gates the applications empty state.
5. **`DynamicClientRegistrationSetting.tsx`**: the section. Two things
worth reading closely: the enable path opens the dialog while the
disable path calls straight through, and lacking permission uses the
native `disabled` attribute while an in-flight request uses
`aria-disabled`, so a keyboard user is not blurred mid-flip.
6. **The two story files**: read last, as they exercise everything above
without a real server. The dialog stories query
`canvasElement.ownerDocument.body` rather than `canvasElement`, since
the dialog renders into a portal attached to `<body>`.
## Deliberately not in this PR
- **ENG-3116**: the applications list cannot distinguish self-registered
clients from admin-created ones. Surfacing that needs a new field on
`codersdk.OAuth2ProviderApp`, which is an API addition this PR does not
need.
- **ENG-3118**: reusing the shared `EnabledBadge` and `SettingsHeader`
primitives for this section. Both hinge on what the mockup intends, and
the badge in particular is a visible change either here or on the four
other pages that share it.
## Screenshots
Default (disabled):
<img width="1676" height="497" alt="image"
src="https://github.com/user-attachments/assets/cfa60266-8678-410e-9577-16ef474491e3"
/>
Enabling (confirmation dialog):
<img width="1661" height="558" alt="image"
src="https://github.com/user-attachments/assets/a7d54fdd-f65d-4fec-9ed9-3bfdcfdae5be"
/>
Enabled:
<img width="1666" height="559" alt="image"
src="https://github.com/user-attachments/assets/d39251c2-771c-4608-81c2-dda151b35c3d"
/>
---------
Co-authored-by: Tracy Johnson <tracy@coder.com>
Replace Emotion `css` / MUI theme tokens in `Markdown` with Tailwind
semantic classes, and swap `InlineMarkdown`'s MUI `Link` for the shared
`Link` component.
Code block colors now use theme tokens (`surface-secondary`,
`syntax-key`, `content-destructive`) instead of hardcoded zinc/teal and
`theme.palette`. Also adds a `WithLink` story for the full Markdown
component.
Replace MUI `TextField` in the Form Storybook stories with `FormField`,
wired through Formik and `getFormHelpers` so the examples match how real
forms use the layout components.
Swaps `chromatic/isChromatic` for `@coder/pixel-storybook`'s `isPixel()`
so the
snapshot-determinism gates (fixed workspace name, frozen Spinner, fixed
CLI
origin, no scroll, font loader) fire under pixel instead of Chromatic.
Drops the
now-unused `chromatic` dependency.
Stacked on #27658 (pixel-storybook 0.3); `isPixel` comes from the new
`@coder/pixel-storybook/storyapi` subpath. Verified the app and
Storybook
builds both bundle `isPixel` cleanly, and `tsc` passes with the
dependency
removed.
This is the last piece of the Chromatic removal; the story params
migrated in
#26844 and the addon came out in #27353.
<details>
<summary>Chromatic removal sequence</summary>
1. Remove the Chromatic CI job + scripts + docs reference. (#26777,
merged)
2. **This PR** — `isChromatic()` → `isPixel()`; drop the `chromatic`
dependency.
3. `data-pixel` + `pixel.exclude`; drop `delay` / `pauseAnimationAtEnd`.
(#26778,
merged)
4. Migrate story snapshot params (`viewports` / `diffThreshold` / theme
modes →
`pixel.matrix`); delete `testHelpers/chromatic.ts`. (#26844, merged)
5. Remove the `@chromatic-com/storybook` addon. (done on main in #27353)
</details>
---
> Generated by Coder Agents on behalf of @aslilac.
Adds a file dropzone to the create branch of the Add secret dialog
(final PR in the PLAT-240 stack, after #26723 and #26724). The browser
reads the file, derives the format from the extension
(`.env`/`.json`/`.yaml`/`.yml`), and imports via `POST /secrets/batch`;
per-entry backend errors surface in an alert and the success toast flags
secrets imported without an env name. Storybook play stories and vitests
cover the flow.
Also documents the upload flow in `docs/user-guides/user-secrets.md`.
Closes https://linear.app/codercom/issue/PLAT-240
> Reviewed and updated by Coder Agents on behalf of @dylanhuff-at-coder.
Free-text member search previously matched only username and email, so
typing a person's display name returned no results even though the UI
shows the display name as the primary label. This broadens the free-text
`@search` filter to also match `users.name`.
The change is in three queries: `GetUsers`,
`PaginatedOrganizationMembers`, and `GetGroupMembersByGroupIDPaginated`.
This covers every server-filtered surface: the Users page, the
Organization Members page, the Group Members page, and the
`UserAutocomplete` / `WorkspaceUserAutocomplete` pickers (which query
`GetUsers` with `q`). The org member picker (`MemberAutocomplete`)
filters client-side via cmdk, so display name is added to its
`keywords`.
Explicit filters (`name:`, `username`/`email`) and pagination counts are
unchanged; the group members count still comes from the filtered
`COUNT(*) OVER()` in the same query.
Refs DEVEX-484
Refs DEVEX-565
<details>
<summary>Implementation plan</summary>
## Problem
Member search (both the global Users page and the Organization Members
page) matches only on `username` and `email`. It does not match on the
user's display name (`users.name`), even though the Organization Members
table shows `name` as the primary title. So typing a person's full name
in the search box returns nothing.
Today a bare search term (`alice`) is routed to the SQL `@search`
filter, which only checks `email`/`username`. Display name is only
matched if the user explicitly types `name:alice`, which is
undiscoverable.
## Design decision
Include `name` in the free-text `@search` condition in the affected SQL
queries. A bare term then matches `email OR username OR name`, using the
same case-insensitive substring `ILIKE` already in place. This keeps the
existing explicit `name:` filter working.
Tradeoff: this broadens the meaning of free-text `search` globally
(anything using these queries now also matches display name). This is
the intended behavior, confirmed against DEVEX-565 (display name search
in the user picker).
## Affected files
Backend:
- `coderd/database/queries/users.sql` (`GetUsers`)
- `coderd/database/queries/organizationmembers.sql`
(`PaginatedOrganizationMembers`)
- `coderd/database/queries/groupmembers.sql`
(`GetGroupMembersByGroupIDPaginated`)
- `coderd/database/queries.sql.go` regenerated via `make gen`
Frontend:
- `site/src/components/UserAutocomplete/UserAutocomplete.tsx` (add
`name` to client-side cmdk keywords)
Tests:
- `coderd/coderdtest/users.go` (shared `UsersFilter` helper): added a
`DisplayNameSearch` case and extended search-based expectations to
include `name`. Exercised by `TestGetUsersFilter`,
`TestGetOrgMembersFilter`, and `TestGetGroupMembersFilter`.
Docs:
- `docs/admin/users/index.md`: documented that free-text search matches
username, email, and display name.
## Frontend surface coverage
| Surface | Sends | Backend | Query |
|---|---|---|---|
| Users page | `q` | `GET /users` | `GetUsers` |
| Organization Members page | `q` | paginated members |
`PaginatedOrganizationMembers` |
| Group Members page | `q` | `groupMembers` |
`GetGroupMembersByGroupIDPaginated` |
| User pickers (server-filtered) | `q` | `GET /users` | `GetUsers` |
| Org member picker (client-filtered) | local cmdk | n/a | keyword
change |
## Out of scope
- Trigram/similarity (fuzzy) matching; keeps `ILIKE` substring
semantics.
- Sort/pagination ordering (still `LOWER(username)`).
</details>
---
_Created by Coder Agents on behalf of @aqandrew._
Tiny simple change just moving us over to Tailwind instead of MUI for
`<Form />`. Functionally equivalent minus the mismatch between `md` in
`Tailwind` (`≥ 768px`) and `MUI` (`≥ 900px`).
This pull-request addresses a few things that may have made this page
less accurate than we would have liked.
* Disable the setup form when it is submitting things to the backend.
* Migrate `<PasswordField />` over to being backed by `<FormField />`
* Don't make use of `<strong />` in the header. Rely on the
`font-semibold` as per other headings in the codebase.
> 🤖 This PR was modified by Coder Agents on behalf of Jake Howell.
## What
Introduces an `AppearanceProvider` / `useAppearance` context that
publishes **user** appearance values derived from the active site theme,
and migrates the current consumers of `theme.externalImages` (`Avatar`,
`ExternalImage`, `IconsPage`) to read from it.
## Why
Today, per-user appearance concerns like `externalImages` are smuggled
onto the Emotion theme object, which forces components to pull in
`useTheme` purely to reach a single appearance value. That couples "how
something looks for this user" to "how the styling engine happens to be
wired", and means every new user-appearance value has to be bolted onto
the theme.
This context gives user appearance a home of its own, decoupled from
Emotion. Components ask for what they actually need (`const {
externalImages } = useAppearance()`) instead of reaching through the
theme.
## Scope: user appearance, not admin appearance
To be explicit, this provider is about **user-level** appearance — the
per-user, theme-derived rendering concerns. It is deliberately *not* the
deployment-level `AppearanceConfig` (application name/logo, service
banners, support/docs links) that admins configure; that is a separate
concern with its own data source and shouldn't be folded in here.
## Future scope
`externalImages` is the **first** value to move here, not the only one.
`Appearance` is deliberately modelled as an open interface so future
*user* appearance state can live in one place without touching every
consumer or overloading the theme again. Likely candidates are other
per-user, theme-derived values, e.g.:
- Terminal font / other typography preferences currently surfaced via
user appearance settings.
- Theme mode and other theme-derived rendering styles that follow the
same "read one value off the theme" pattern as `externalImages`.
- Accessibility-oriented rendering preferences (e.g. reduced motion) as
they're added.
Centralising these behind a single provider keeps consumers stable as
the surface grows and avoids re-litigating the `useTheme` coupling each
time (laziness now, less maintenance later).
## Changes
- Add `site/src/theme/appearance.tsx` (`AppearanceProvider`,
`useAppearance`), defaulting `externalImages` to `forDarkThemes` to
match `DEFAULT_THEME`.
- Wrap children with `AppearanceProvider` in `ThemeOverride` and in the
Storybook preview decorator.
- Migrate `Avatar`, `ExternalImage`, and `IconsPage` off
`theme.externalImages` and onto `useAppearance`.
## Notes
- Kept as a **draft** pending the go-ahead to open for review.
- No behavioural change intended; this is a plumbing/refactor step.
The group members table hid a member's spend behind a bare "Unlimited"
label when their budget resolves to a group with no limit, and rendered
a $0 budget as a special "None" label. Spend now always shows: as
"$X / Unlimited USD" for unlimited budgets, and as a normal limit row
("Group limit $0", exceeded color once spend is above zero) for $0
budgets.
The Everyone badge drops "(not allocated)" when the Everyone group's
own budget or a user override applies, showing "Everyone" or
"Everyone (individual)" instead. The not-attributed tooltip now states
that the amount is the user's spend in the viewed group and that their
AI budget is managed by another group, replacing the misleading
"Not attributed to this group" wording.
## Problem
On mobile viewports every settings shell renders a fixed 240px sidebar
next to the content in a non-responsive flex row, leaving the content
almost no width. The MCP Servers page is worst: its fixed-layout table
cannot shrink, so the whole page collapses into a single cramped column.
## Changes
- `Sidebar` component: full width below the `lg` breakpoint, `w-60` at
`lg`+.
- Six settings layouts (AI, Deployment, Organization, User, Workspace,
Template) stack the nav above the content below `lg` and keep the
previous side-by-side spacing at `lg`+. Content wrappers get `min-w-0`
so flex children cannot force overflow, and horizontal padding tightens
on small screens.
- MCP servers table gets `min-w-[640px]` so the existing `overflow-auto`
wrapper provides horizontal scrolling instead of colliding column
headers.
Tailwind class changes only; no component or behavior changes.
## Testing
- `pnpm check` and `pnpm run lint:types` pass.
- Dogfood UAT on a dev deployment: at 390x844 the MCP Servers,
Deployment, Account, and Template settings pages stack nav-above-content
with no horizontal body overflow and a scrollable MCP table; boundary
verified stacked at 1023px and side-by-side at 1024px; at 1440x900 the
layouts are pixel-identical to before (240px sidebar, 112px gap, 40px
padding).
Closes CODAGT-828.
> Created by Mux, an AI agent, on Mike's behalf.
On mobile-width viewports the workspace page grew to ~1080px wide (390px
viewport), pushing the schedule controls, status indicator, and action
buttons off-screen. Users had to zoom out or scroll horizontally to
reach Stop/Restart/Favorite/Share.
The topbar header and the right-side controls group used non-wrapping
flex rows, and every child is a `whitespace-nowrap` button, so the row's
minimum width (~725px for the controls alone) forced the document wider
than the viewport.
## Changes
- `WorkspaceTopbar`: allow the topbar header and the right controls
group to wrap; right controls stay right-aligned when they wrap onto
their own row.
- `WorkspaceActions`: allow the button row to wrap.
- `TopbarData`: merge a caller-provided `className` (no existing caller
passed one); the workspace breadcrumbs use it to wrap on very narrow
screens.
## Verification
Compared before/after with agent-browser on Storybook stories
(`Workspace: Running`, `WorkspaceTopbar: Outdated / WithQuotaWithOrgs /
Dormant / ConnectedWithMaxDeadline`):
| Viewport | Before `scrollWidth` | After `scrollWidth` |
|---|---|---|
| 320 | 1080 (overflow) | 305 (fits) |
| 390 | 1080 (overflow) | 375 (fits) |
| 768 | 1080 (overflow) | 753 (fits) |
| 1024 | 1080 (overflow) | 1009 (fits) |
| 1440 | 1425 (fits) | 1425, pixel-identical (0% diff) |
At widths >= 1280 the layout is unchanged (0% pixel diff at 1440 across
the stories above). Between ~1080-1230px the controls now wrap onto a
second right-aligned row instead of squeezing label text onto two lines
inside a single row.
Validation: `biome check`, `tsc --noEmit`, and `pnpm test:storybook` for
`Workspace.stories.tsx` + `WorkspaceTopbar.stories.tsx` (36 passed).
> This PR was prepared by Mux, an AI coding agent, on Mike's behalf.
Finishing touches for the AI cost control group members table.
- Spend stays primary-colored until near the budget limit, via a new
AIBudgetAmount component
- "AI budget period" label shows the current spend window in local
time, next to the members tab
- Budget tooltip notes the reset date and the group's default limit
- "Budget type" renamed to "Budget group", with a badge for the
governing group or override
- Distinguishes $0 budget ("None") from no budget ("Unlimited")
- Unattributed spend from another group shows a note instead of a dash
- "Manage AI budget" disabled only when another named group governs
- Replaces UserAISpend with generated UserAISpendStatus, fixing
limit_source
Closes#26401