Commit Graph
1519 Commits
Author SHA1 Message Date
Ethan 75ad45109d feat: use organization-scoped chat models (#27960)
Move chat model settings to
`/ai/settings/organizations/:organization/models`, add an organization
switcher, and redirect legacy settings paths. The pages use organization
permissions for read, create, update, and delete controls. They use
redacted provider descriptors.

Agent creation loads models for the selected organization. Existing
chats load models for the chat organization. Chat views warn about
unavailable historical models, select a usable local recovery model, and
disable generation when none exists. Settings that aggregate
organizations retain successful results when one request fails.

Depends on #27959

_This pull request description was generated by Coder Agents._
2026-08-24 11:20:25 +01:00
david-fraley a2afb458c5 feat: add premium paywall conversion telemetry (#28425)
**TLDR:** When someone using free Coder taps a "Start trial for free"
button, we now write down which page they tapped it on, and whether they
went on to actually start the trial. That tells us which locked features
make people want Premium.

## Brief summary

- Records two things: the button being clicked, and a trial actually
being started.
- Each click gets a random ID that travels with the person to the trial
form, so we can tell who finished from who walked away.
- Records a short label for the page (like `appearance` or `audit_log`),
never the web address, so no customer names are collected.

#28226 added the trial form and has since merged, so this now targets
`main`.

## Description

Today the "Start trial for free" button just moves you to the Premium
page and nothing is recorded, so there is no way to know which feature
convinced someone.

This adds two events:

| Event | Happens when |
|---|---|
| `cta_click` | Someone clicks "Start trial for free" |
| `trial_signup` | A trial licence is actually issued |

The click generates a random ID. It rides along to the trial form and
gets saved on the signup, so a click and a signup can be matched up
later. Clicks with no matching signup are the people who gave up. Trials
started without a paywall (for example straight from the sidebar) are
labelled `direct`, so "no label" never gets confused with "broken".

A few small choices worth knowing:

- The ID is kept in browser session storage, not in the web address, so
the URL stays clean. It expires after 30 minutes.
- The trial signup event is recorded by the server, not the browser, so
nobody can fake a signup.
- The browser cannot say which kind of event it is sending. It can only
report a click; the server labels it.
- Only people allowed to start a trial are counted. The server rejects
the rest, which matters because a few paywalls show their button to
everyone.
- Nothing is recorded at all when telemetry is turned off.

There is deliberately no "paywall was shown" event. Every admin page
visit would report one, which is a lot of noise for little insight.

Every paywall in the [Premium Copy
Updates](https://www.notion.so/coderhq/Premium-Copy-Updates-3c1d579be59280a59557cabd54fd8a1f)
table has a label. The rows that do not are badges and alerts with no
button to click.

## Proof

Ran a local Coder with no licence and clicked the paywall button on the
Appearance page.

What the browser sent, and the reply:

```json
POST /api/v2/deployment/premium-funnel-events  ->  204 No Content
{"id":"92d96afa-f975-4789-b8a7-b1fd19d4df85","source":"appearance","variant":"premium"}
```

What the browser saved for the trial form, same ID:

```json
{"id":"92d96afa-f975-4789-b8a7-b1fd19d4df85","source":"appearance","createdAt":1787268520857}
```

The click then landed on the Premium page with the trial form.
Screenshots of the network panel, the saved value, and the Premium page
are attached below. The form itself was not submitted, since that would
contact the real licence server.

## Testing

- Backend tests: a click is recorded with the server's own label, an
unknown page label is refused, and a normal member is refused.
- Storybook tests: button clicked and ID saved on all three paywall
styles, plus the case where the viewer only sees guidance text and has
nothing to click.
- Unit tests for the saved ID: expiry, bad data, unknown page label, and
the `direct` fallback.
- `make gen` is clean, and lint, types, and formatting pass.

Two stories fail on `main` today and fail the same way here:
`ExternalAuthSettingsPageView > Page` and `PremiumPageView > No
License`. Neither file is touched by this branch; both were verified
failing on `main` with this branch checked out elsewhere.

## Follow-up

coder/coder-telemetry-server#45 saves these events for reporting. It
merges after this one.

<details>
<summary>Plan and decisions</summary>

**Goal:** find out which Premium paywall drives trial signups, and tell
abandons apart from completions.

**Options considered and dropped:**

1. Put `?from=appearance` in the URL. Rejected: messy for users.
2. Use router state. Rejected: lost if the page is refreshed.
3. Let the browser report the signup. Rejected: it could be faked or
lost during the redirect.
4. Work out the page from the URL. Rejected: URLs contain organization
and template names.
5. Report every paywall impression. Rejected: one event per admin page
visit is noise, and the click is the signal.
6. Separate event types and tables. Rejected: makes the matching query
harder for no gain.

**Event shape:**

```go
type PremiumFunnelEvent struct {
	ID            uuid.UUID // event ID; for a click this is also the matching ID
	EventType     string    // cta_click | trial_signup, set by the server
	Source        string    // appearance, audit_log, ..., direct
	Variant       string    // which paywall design was shown
	AttributionID uuid.UUID // the click this signup came from
	UserID        uuid.UUID
	CreatedAt     time.Time
}
```

**Where the code lives:** nothing under `components/` imports from
`modules/`, so the paywall components stay presentational and only gain
an `onCTAClick` prop. New wrappers in `modules/paywall/` own the
reporting, one per paywall style, each taking a fixed `source`.

**Self-review:** the `frontend-review` skill caught that reading
permissions inside the wrapper forced an auth context into
presentational page views, which broke their stories. Every call site
already passes `canViewPremium={permissions.viewAllLicenses}` and the
button only renders when that is true, so the wrapper no longer reads
permissions and the server stays the gate. It also flagged one
unattributed paywall on the Security page, now wired up as
`browser_only`.

</details>

> Generated with [Coder Agents](https://coder.com/agents) on behalf of
@david-fraley
2026-08-21 10:31:01 -04:00
Samuel VolinandMatt Vollmer f44a3b59d7 feat: Premium Page CTA updates and form handling (#28226)
* Premium page has form for signing up for a trial
* Input validation and normalization for trial form fields
* api endpoint definition to make this processes seamless 
* license page updates to access `?success=true` queryparam and confetti

<img width="1512" height="833" alt="Screenshot 2026-08-17 at 10 08
41 PM"
src="https://github.com/user-attachments/assets/1766e724-c63a-4da9-b2bd-a98e6b66cc9b"
/>

<img width="1510" height="874" alt="Aug-17-2026 22-12-38"
src="https://github.com/user-attachments/assets/3a2a0f35-1176-4290-aa0d-b3f7d66c15d6"
/>


```mermaid
sequenceDiagram
    autonumber
    actor Owner
    participant UI as UI<br/>TrialRequestForm + PremiumPage
    participant API as API<br/>api.ts + coderd middleware
    participant Coderd as Coderd<br/>postTrialLicense + trialer
    participant Licensor as Licensor<br/>v2-licensor + Postgres + pubsub + cache

    Note over UI: mutation.status = "idle"

    Owner->>UI: fills 10 fields, checks acknowledgement
    UI->>UI: Yup validate, strip "acknowledged", submit
    UI->>API: POST /api/v2/licenses/trial<br/>cookie + X-CSRF-TOKEN
    Note over UI: mutation.status = "pending"

    API->>API: verify CSRF, apiRateLimiter,<br/>apiKeyMiddleware sets actor
    API->>Coderd: dispatch

    Coderd->>Coderd: audit.InitRequest, authorize license:create,<br/>httpapi.Read validates, HasLicense is false
    Coderd->>Licensor: POST trial request<br/>deployment_id unspoofable, 10s deadline
    Licensor-->>Coderd: 200, raw signed JWT

    Coderd->>Coderd: ParseClaimsIgnoreNbf, uuid.Parse
    Coderd->>Licensor: InsertLicense, updateEntitlements,<br/>publish PubsubEventLicense, commit audit log
    Licensor-->>Coderd: database.License
    Coderd-->>API: 201 codersdk.License
    API-->>UI: 201 codersdk.License

    Note over UI: mutation.status = "success"
    UI->>Licensor: invalidate entitlements + licenses keys
    UI->>Owner: navigate /deployment/premium?success=true
    Note over Owner: confetti fires on the success param
```

---------

Co-authored-by: Matt Vollmer <matthewjvollmer@outlook.com>
2026-08-21 09:54:54 -04:00
TJ fd09112e28 fix(site): right-align DateTimeRangePicker dropdown to match other dropdowns (#28428)
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.
2026-08-20 23:54:53 +00:00
TJ 3ffb266af6 fix(site): align settings header style and paywall spacing (#28418)
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._
2026-08-20 23:08:51 +00:00
TJ 1c139402d0 feat(site/src): use DateTimeRangePicker on the AI sessions page (#28426)
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.
2026-08-20 23:08:00 +00:00
TJ af05849c9c feat(site/src/components): add date-time range picker with quick picks (#28392)
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.
2026-08-20 22:32:41 +00:00
david-fraley e7eea0f06f feat: update premium paywall copy and link each page to its feature docs (#28339) 2026-08-20 17:06:21 -05:00
TJ cfc7b825c2 fix(site/src/components/MultiSelectCombobox): match Input height when empty (#28409)
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.
2026-08-20 21:55:59 +00:00
Danielle Maywood 4a7a0a3348 feat: label process_output rows with the process command (#28300) 2026-08-20 11:56:14 +01:00
Michael Suchacz 24b83dc727 feat: add organization picker to MCP server settings pages (#28150)
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.
2026-08-19 19:46:23 +00:00
Jake Howell 46c0dba1e9 fix: standardize EmptyState typography (#27720) 2026-08-19 22:01:58 +07:00
Cian Johnston da99941099 feat(site): add shared DateTimeRangeFilter component (#28255)
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)
2026-08-19 13:47:06 +01:00
Cian Johnston 962366ffc6 fix(site): quote colon-containing values in filter serialization (#28254)
`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)
2026-08-18 17:01:42 +01:00
Michael Suchacz 5af08a1a09 fix(site): repair the locally-run storybook vitest suite (#28261) 2026-08-18 17:07:39 +02:00
Jeremy RuppelandSamuel Volin 46ec620767 fix(site): deflake adjust user theme preference (#28219)
## 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>
2026-08-17 15:36:46 -04:00
TJ 5d746aa594 fix(site/src): replace hardcoded text-[13px] with scale tokens outside agents (#28071)
Replaces the arbitrary `text-[13px]` value with the design-system tokens
`text-sm` (14px) or `text-xs` (12px) in the 18 non-agents files that
used it. `AgentsPage` usages and `modules/resources/AgentMetadata.tsx`
are intentionally left alone; they'll be handled with the agents UI
separately.

Because the custom Tailwind scale bakes `font-weight: 500` into
`text-xs`/`text-sm`, `font-normal` was added wherever the text
previously rendered at 400 and is prose, code, or log content, so only
the size changes there. Short labels, headers, and numeric values take
the token's 500 weight as-is.

### Token decisions

| File | Token | Rationale |
|---|---|---|
| `components/Logs/LogLine.tsx` | `text-xs font-normal` | Dense mono log
output; 12px keeps line-height close to current |
| `components/PaginationWidget/PaginationAmount.tsx` | `text-xs
font-normal` | Caption-style "showing X of Y" text |
| `pages/IconsPage/IconsPage.tsx` (figcaption) | `text-xs font-normal` |
88px-wide icon captions |
| `pages/WorkspacesPage/WorkspacesButton.tsx` | `text-xs font-normal` |
Secondary line under the template name in the combobox |
| `modules/templates/TemplateExampleCard.tsx`,
`pages/CreateTemplateGalleryPage/...` | `text-xs font-normal` |
Secondary card description prose (and its "Read more" link) |
| `components/FullPageLayout/Sidebar.tsx` / `Topbar.tsx` | `text-sm`
(Topbar adds `font-normal`) | Nav chrome; Topbar is a container so
`font-normal` avoids leaking 500 into all children |
| `components/Paywall/PaywallPremium.tsx` | `text-sm font-normal` |
Feature list prose (compact variant) |
| `modules/templates/TemplateFiles/TemplateFiles.tsx` /
`TemplateFileTree.tsx` | `text-sm` | File headers/tree labels (header
already `font-medium`) |
| `modules/workspaces/WorkspaceOutdatedTooltip.tsx` | `text-sm
font-normal` | Tooltip body prose |
| `pages/WorkspacePage/ResourcesSidebar.tsx` | `text-sm font-normal` |
Help text prose |
| `pages/AISettingsPage/.../CredentialField.tsx` | `text-sm font-normal`
| Mono credential input |
| `pages/DeploymentSettingsPage/Option.tsx` | `text-sm` | Already
`font-semibold` |
| `pages/HealthPage/Content.tsx` | `text-sm font-normal` | Mono detail
block |
| `pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx` |
`text-sm` | "Files" panel header label |
| `pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx` |
`text-sm` (`font-normal` on prose/empty state) | Data labels and values
in insight panels |

### Storybook review checklist

Stories directly covering changed components:

- `Logs/LogLine` and `Logs/Logs`
- `Paywall/PaywallPremium`
- `TemplateExampleCard`
- `TemplateFiles` and `TemplateFileTree`
- `WorkspaceOutdatedTooltip`
- `CreateTemplateGalleryPageView`
- `IconsPage`
- `TemplateInsightsPage`
- `TemplateVersionEditor` (also exercises FullPageLayout
`Topbar`/`Sidebar`)

Indirect coverage for components without their own stories:

- `PaginationAmount` → `PaginationWidget/PaginationContainer` stories,
plus paginated page views (`UsersPageView`, `AuditPageView`,
`ConnectionLogPageView`)
- `WorkspacesButton` → `WorkspacesPageView` stories (open the "New
workspace" combobox)
- `HealthPage/Content` → `HealthPage/*Page` stories (`DERPPage`,
`DatabasePage`, etc.)
- `ResourcesSidebar` → `WorkspacePage/Workspace` stories (failed-build
state)
- `FullPageLayout Topbar/Sidebar` → `TemplateVersionEditor` stories
- `CredentialField` → `ProviderForm` / `AddProviderPageView` stories

No story exists for `DeploymentSettingsPage/Option`; verify on the
deployment settings page (option value pills).

---

🤖 This PR was generated by Coder Agents on behalf of @tracyjohnsonux.
2026-08-17 18:41:06 +00:00
Jake Howell ea8ba0c678 refactor: remove MUI and Emotion (#27821)
> 🤖 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.
2026-08-17 14:04:48 +07:00
Samuel Volin b26f4c0e03 feat: DEVEX-751 updated premiumpaywall component (#28070)
* implements the supergraphic component, to show a minified webp
theme-aware version of the supergraphic
* refine the PaywallPremium into separate PaywallSmall component
* paywall no long includes link to docs for component; will be part of
DEVEX-742
* appearance and custom roles page uses PaywallSmall

<img width="1509" height="676" alt="Screenshot 2026-08-12 at 7 49 54 AM"
src="https://github.com/user-attachments/assets/17cd1a16-acc8-415a-819d-bdb9259fdaf9"
/>
<img width="1499" height="702" alt="Screenshot 2026-08-11 at 12 46
55 PM"
src="https://github.com/user-attachments/assets/05cd628a-c8c1-4b1c-a4c9-318814eec428"
/>
<img width="1466" height="549" alt="Screenshot 2026-08-11 at 12 44
43 PM"
src="https://github.com/user-attachments/assets/f0bb0c0e-7702-41a0-a718-78a637c9f4e9"
/>
<img width="1715" height="652" alt="Screenshot 2026-08-12 at 11 25
21 AM"
src="https://github.com/user-attachments/assets/55dedd2c-d44c-4cd0-a425-fbde4452f622"
/>
2026-08-13 15:21:22 -06:00
Jake Howell 726e86cef7 refactor(site): add <Drawer /> and migrate build logs drawer (#28009)
> 🤖 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.
2026-08-12 13:51:04 +10:00
McKayla はな efd5ce3668 test: fix failing storybook stories (#27674) 2026-08-10 17:03:28 -06:00
Andrew Aquino 87fdd2b59b feat(site): add server-side search and pagination for groups page (#27994)
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.
2026-08-10 13:23:14 -07:00
McKayla はな 09b0d8e108 chore: update stale Chromatic comments (#27714) 2026-08-10 14:15:28 -06:00
Jake Howell 7a9b4250aa refactor(site): demui <IconField /> (#27719)
> 🤖 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.
2026-08-07 09:56:05 +10:00
Samuel Volin 50640063a2 feat: DEVEX-732 premium badging (#27847)
Premium badging and gating consistency

as a OSS user, I want to be upsold to premium, and tastefully

Summary

Standardize all base-Premium full-page gates and the two named inline
notices. Admins see an in-app “Learn about Premium” path; non-admins are
told to contact their deployment administrator.
* DEVEX-732
* updates for premium docs pages for consistency
* updates for premium badging and paywall components
* updates implemented uses of premium badge and premiumpaywall


| Before | After |
| --- | ----------- |
| <img width="1271" height="564" alt="Screenshot 2026-08-04 at 3 02
32 PM"
src="https://github.com/user-attachments/assets/027d4bca-3e34-40b2-ad69-28dbaa4a004b"
/> | <img width="1273" height="600" alt="Screenshot 2026-08-04 at 3 26
37 PM"
src="https://github.com/user-attachments/assets/321083c0-7a4c-4c7e-a19c-059807018d3b"
/> |

| Before | After |
| --- | ----------- |
| <img width="1084" height="672" alt="image"
src="https://github.com/user-attachments/assets/741e6bd9-93b0-4ae0-97df-027e8aba5716"
/> | <img width="1289" height="622" alt="Screenshot 2026-08-04 at 3 20
07 PM"
src="https://github.com/user-attachments/assets/b3a8c169-ca6e-439b-8752-9209131fc097"
/> |

| Before | After |
| --- | ----------- |
| <img width="1091" height="865" alt="image (1)"
src="https://github.com/user-attachments/assets/f7cd92dd-a975-4db0-bc2a-af092ba783ce"
/> | <img width="1268" height="680" alt="Screenshot 2026-08-04 at 3 37
06 PM"
src="https://github.com/user-attachments/assets/8af8081a-0ec9-4fd3-921c-470127f2328b"
/> |
2026-08-06 12:13:12 -06:00
Jake Howell c98d6bb535 refactor(site/src): migrate shared Pill to Badge (#27638)
> 🤖 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>
2026-08-06 12:36:05 +10:00
Jake Howell a79031a3f5 refactor(site): migrate WorkspacesButton to Combobox (#27723)
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.
2026-08-06 01:28:25 +00:00
Jake HowellandDanielle Maywood 05baba1e63 test(site): fix failing Storybook play functions (#27874)
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>
2026-08-05 10:06:34 +00:00
Jake Howell 7a4ae2649e refactor(site): replace MUI Stack, TextField, and Link with shared components (#27817)
> 🤖 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")._
2026-08-05 14:07:04 +10:00
Jake Howell 209c990888 refactor(site): demui template settings forms (#27636)
> 🤖 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.
2026-08-05 01:00:18 +10:00
Jake Howell cce5a21933 chore(site): remove unused SelectMenu component (#27800)
`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.
2026-08-04 13:22:52 +10:00
Bobby HoandTracy Johnson 4245e4e378 feat: expose dynamic client registration in deployment settings (#27480)
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>
2026-08-03 08:29:35 -07:00
Jake Howell 20ff957c74 refactor(site): demui workspace topbar, schedule controls, and timings (#27790) 2026-08-03 15:04:23 +00:00
Jake Howell fba9f0d485 refactor(site): migrate <Markdown /> and <InlineMarkdown /> off MUI (#27724)
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.
2026-08-03 19:26:58 +10:00
Jake Howell 0a42334f77 refactor(site): demui Form.stories.tsx stories (#27762)
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.
2026-08-03 19:08:30 +10:00
Jake Howell 79724ab0ba chore(site): migrate all <Dialog />s off MUI (#27506)
> 🤖 This PR was modified by Coder Agents on behalf of Jake Howell.

Removes Material UI from every dialog, moving them onto the internal
shadcn/radix `Dialog` primitives. After this change there are **no**
`@mui/material/Dialog` usages left in `site/src`.

## Changes

- Consolidated `components/Dialogs/*` → `components/Dialog/*` and folded
the old `ConfirmDeleteDialog` into `ConfirmDialog` (`type="delete"`).
- Rewrote `ConfirmDialog`, `DeleteDialog`, `WorkspaceDeleteDialog`,
`ScheduleDialog`, and `AnnouncementBannerDialog` onto the internal
primitives (native `Input`/`Label`/`Checkbox`/`Link` instead of MUI).
- Finished the migration for the last two MUI hold-outs:
`UpdateBuildParametersDialog` and `MissingTemplateVariablesDialog`.
- Dialog prop types now compose the rendered component's props
(`ComponentProps<typeof Dialog>` / MUI `DialogProps`) instead of
hand-rolled `{ open; onOpenChange }` shapes.

## Testing

AI-Driven manual dogfood sweep in a live instance (premium license),
driven in a browser. Each dialog checked for logical rendering (layout,
variant styling, buttons, no overlap/blank/console error) and function
(open, primary action, cancel/close, guard states):

| Dialog / surface | How tested | Result |
| --- | --- | --- |
| `ConfirmDialog` (delete / info / success) | Token delete,
update-confirm, change-version | ✅ |
| `DeleteDialog` (type-to-confirm) | Delete user, group, license,
provider, OAuth2 app | ✅ |
| `WorkspaceDeleteDialog` | Workspace actions → Delete (+ orphan path
via failed workspace) | ✅ |
| `ScheduleDialog` | Template schedule → dormancy/deletion warning | ✅ |
| `AnnouncementBannerDialog` | Deployment → Appearance → New banner
(live preview + color) | ✅ |
| `ChangeWorkspaceVersionDialog` | Workspace actions → Change version |
✅ |
| `DownloadLogsDialog` | Workspace actions → Download logs | ✅ |
| Batch delete (workspaces) | Workspaces list → multi-select → Delete |
✅ |
| `TemplatePageHeader` delete | Template → Delete (cancelled) | ✅ |
| `FileDialog` (create/rename/delete) | Template editor file tree | ✅ |
| `MissingTemplateVariablesDialog` *(migrated)* | Editor → add variable
→ Build | ✅ |
| `PublishTemplateVersionDialog` | Editor → Publish | ✅ |
| `UpdateBuildParametersDialog` *(migrated)* | Classic flow + required
param → workspace Update (renders + submits) | ✅ |
| Update-confirmation (`WorkspaceUpdateDialogs`) | Workspace Update | ✅
|
| Suspend/activate confirm | Users → member row | ✅ |
| `ResetPasswordDialog` | Users → member → Reset password | ✅ |
| Token delete confirm | Settings → Tokens | ✅ |
| Create-token confirm | Token create flow | ✅ |
| SSH key regenerate confirm | Settings → SSH Keys | ✅ |
| Change-login-type confirm | Settings → Security | ✅ |
| Secret delete | Settings → Secrets | ✅ |
| Group delete | Admin → Groups | ✅ |
| Org member remove | Admin → Organization → Members | ✅ |
| License remove | Deployment → Licenses (cancelled) | ✅ |
| Announcement banner delete | Deployment → Appearance | ✅ |
| OAuth2 app delete | Deployment → OAuth2 apps | ✅ |
| `ModelFormDialogs` (form + delete) | AI → Models | ✅ |
| Provider delete | AI → Providers | ✅ |
| Gateway key create/delete | AI → Gateway keys | ✅ |
| `MCPServerFormDialogs` delete | AI → MCP servers | ✅ |
| Personal skill (create form + delete) | Agents → Personal Skills | ✅ |
| Spend user-override (add + delete) | AI → Spend | ✅ |

Human tested:

- [x] template version promote/archive
- [x] external-auth/OAuth2-provider delete 
- [x] custom-role delete
- [x] cancel-provisioner-job

Things that have a chance to bleed:

- tasks dialogs
- dormant inline confirm

Should be known that each of these renders through the already-verified
`ConfirmDialog`/`DeleteDialog`, so the underlying component is covered
even where the specific trigger wasn't reachable.

<details>
<summary>Plan &amp; decision log</summary>

**Goal:** finish the de-MUI migration everywhere and confirm every
affected modal renders logically and functions.

**Phase 1 - complete the migration**

- Confirmed only two files still rendered MUI `Dialog`
(`UpdateBuildParametersDialog`, `MissingTemplateVariablesDialog`);
ported both to the internal primitives, preserving the `{ open, onClose,
... }` public API (mapped to `onOpenChange` internally) so call sites
were unchanged. radix now wires `aria-labelledby`/`aria-describedby`,
removing a duplicated element id.

**Phase 2 - sighting sweep (live browser, premium license)**

- Batch A (workspaces/tasks): 4 PASS, rest state-gated.
- Batch B (templates): `MissingTemplateVariablesDialog`, `FileDialog`,
`PublishTemplateVersionDialog`, template delete - all PASS.
- `UpdateBuildParametersDialog`: reached by enabling classic parameter
flow + pushing a version with a required parameter - PASS (renders +
submits).
- Batch C (users/org/settings): 10 PASS.
- Batch D (deployment/AI): 10 PASS.

**Decisions**

- Kept `ConfirmDialog`-wrapper prop types explicit (composing
`DialogProps` there reintroduced a MUI smell).
- Dropped the unused `ConfirmDialogType` export (knip) and updated the
`WorkspacePage` orphan-delete test: the radix `Checkbox` puts the test
id on the `role=checkbox` button itself, so the previous
`within(...).getByRole` no longer matched.

</details>
2026-07-31 03:23:00 +00:00
McKayla はな 6cfefc0685 chore: replace isChromatic with isPixel (#26832)
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.
2026-07-30 11:47:26 -06:00
McKayla はな 2b28515d9b refactor: migrate story snapshot params to pixel (#26844) 2026-07-29 18:13:55 -06:00
dylanhuff-at-coder efbf802319 feat: add bulk secret import upload to Add secret dialog (PLAT-240) (#26725)
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.
2026-07-28 15:30:15 -07:00
Andrew Aquino 09a69e624a feat: search users by display name (#27398)
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._
2026-07-28 12:13:58 -07:00
Jake Howell 2794886688 fix: demui <Form /> emotion usage (#27560)
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`).
2026-07-28 04:25:07 +00:00
Jake Howell 072b101624 chore: setup page cleanup (#24649)
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.
2026-07-28 03:39:42 +00:00
McKayla はな 00d134ebfd chore: remove classic parameter UI (#25014) 2026-07-27 19:02:28 -06:00
Jake Howell 88c7304e0b feat: add AppearanceProvider to decouple externalImages from theme (#27197)
> 🤖 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.
2026-07-27 08:54:28 +00:00
McKayla はな 6120fb5988 refactor(site): clean up workspace and template settings layouts (#25209) 2026-07-25 11:45:22 -06:00
McKayla はな 4f50b77ac5 fix: tweak Pill styles (#27475) 2026-07-23 19:03:00 -06:00
Ehab Younes 10624122c5 feat(site/src): show spend for unlimited and zero AI budgets (#27458)
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.
2026-07-23 17:38:05 +00:00
Michael Suchacz f18c664be2 fix(site): stack settings layouts on mobile (#27334)
## 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.
2026-07-20 19:35:23 +02:00
Michael Suchacz 36e36e2048 fix(site): wrap workspace topbar controls on narrow viewports (#27313)
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.
2026-07-17 18:12:25 +02:00
Jake Howell bf57da58e7 chore(site): replace CircularProgress with Spinner component (#24276) 2026-07-15 00:26:43 +10:00