| author | |
| committer | |
| log | 242816af805b048aec653d6cc359ecc784ef98c5 |
| tree | 01638c19343bd019e9a2ec1a123e9b28aa159308 |
| parent | bed0b4cdad41408bb09b445a0a8f8ea0e5b62d1b |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>11 files changed, 383 insertions(+), 346 deletions(-)
apps/web/src/components/settings/ProjectDefaultsSettings.tsx+130-120| ... | ... | @@ -129,97 +129,151 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting |
| 129 | 129 | updateSettings({ defaultModelSelection: value }); |
| 130 | 130 | }; |
| 131 | 131 | |
| 132 | const modelRow = ( | |
| 133 | <SettingsRow | |
| 134 | serverScoped | |
| 135 | settingKeys={["defaultModelSelection"]} | |
| 136 | mixed={mixedModel} | |
| 137 | id="default-model" | |
| 138 | title="Model" | |
| 139 | description={ | |
| 140 | isProjectScope | |
| 141 | ? "Model for new threads in this project." | |
| 142 | : "Default model for new threads. Projects can override it." | |
| 143 | } | |
| 144 | status={ | |
| 145 | unavailable || mixedModel || modelSource === "project" | |
| 146 | ? undefined | |
| 147 | : settings.defaultModelSelection === null | |
| 148 | ? "Automatic" | |
| 149 | : undefined | |
| 150 | } | |
| 151 | resetAction={ | |
| 152 | settings.defaultModelSelection !== null ? ( | |
| 153 | <SettingResetButton label="default model" onClick={() => setModel(null)} /> | |
| 154 | ) : null | |
| 155 | } | |
| 156 | control={ | |
| 157 | selection && activeEntry ? ( | |
| 158 | <div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5"> | |
| 159 | <ProviderModelPicker | |
| 160 | activeInstanceId={selection.instanceId} | |
| 161 | model={selection.model} | |
| 162 | lockedProvider={null} | |
| 163 | instanceEntries={entries} | |
| 164 | modelOptionsByInstance={modelOptions} | |
| 165 | triggerVariant="outline" | |
| 166 | triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} | |
| 167 | {...(mixedModel ? { triggerLabel: "Mixed" } : {})} | |
| 168 | getModelDisabledReason={modelDisabledReason} | |
| 169 | onOpenProviderSetup={(instanceId) => { | |
| 170 | if (representative) | |
| 171 | void navigate({ | |
| 172 | to: "/settings/providers", | |
| 173 | search: { environmentId: representative.environmentId, instanceId }, | |
| 174 | }); | |
| 175 | }} | |
| 176 | onInstanceModelChange={(instanceId, model) => | |
| 177 | setModel(createModelSelection(instanceId, model)) | |
| 178 | } | |
| 179 | /> | |
| 180 | {!mixedModel ? ( | |
| 181 | <TraitsPicker | |
| 182 | provider={activeEntry.driverKind} | |
| 183 | models={activeEntry.models} | |
| 184 | model={selection.model} | |
| 185 | prompt="" | |
| 186 | onPromptChange={() => {}} | |
| 187 | modelOptions={selection.options ?? []} | |
| 188 | allowPromptInjectedEffort={false} | |
| 189 | planModeEnabled={settings.planModeEnabled} | |
| 190 | triggerVariant="outline" | |
| 191 | triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} | |
| 192 | onModelOptionsChange={(options) => | |
| 193 | setModel(createModelSelection(selection.instanceId, selection.model, options)) | |
| 194 | } | |
| 195 | /> | |
| 196 | ) : null} | |
| 197 | </div> | |
| 198 | ) : ( | |
| 199 | <span className="text-sm text-muted-foreground">No providers available</span> | |
| 200 | ) | |
| 201 | } | |
| 202 | /> | |
| 203 | ); | |
| 204 | const workspaceRow = ( | |
| 205 | <SettingsRow | |
| 206 | serverScoped | |
| 207 | settingKeys={["defaultThreadEnvMode"]} | |
| 208 | mixed={mixedWorkspace} | |
| 209 | id={searchableSetting("new-threads").id} | |
| 210 | title="Workspace" | |
| 211 | description={ | |
| 212 | isProjectScope | |
| 213 | ? "Where new threads in this project start." | |
| 214 | : "Where new threads start. Projects and their t3.json can override it." | |
| 215 | } | |
| 216 | resetAction={ | |
| 217 | !isProjectScope && settings.defaultThreadEnvMode !== null ? ( | |
| 218 | <SettingResetButton | |
| 219 | label="default workspace" | |
| 220 | onClick={() => updateSettings({ defaultThreadEnvMode: null })} | |
| 221 | /> | |
| 222 | ) : null | |
| 223 | } | |
| 224 | control={ | |
| 225 | <Select | |
| 226 | value={mixedWorkspace ? null : (effective?.defaultThreadEnvMode ?? null)} | |
| 227 | onValueChange={(value) => { | |
| 228 | if (value === "local" || value === "worktree") | |
| 229 | updateSettings({ defaultThreadEnvMode: value }); | |
| 230 | }} | |
| 231 | > | |
| 232 | <SelectTrigger size="sm" aria-label="Default workspace"> | |
| 233 | <SelectValue> | |
| 234 | {(value: string | null) => | |
| 235 | value === "local" || value === "worktree" | |
| 236 | ? resolveEnvModeLabel(value) | |
| 237 | : unavailable | |
| 238 | ? "Unavailable" | |
| 239 | : "Mixed" | |
| 240 | } | |
| 241 | </SelectValue> | |
| 242 | </SelectTrigger> | |
| 243 | <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 244 | <SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem> | |
| 245 | <SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem> | |
| 246 | </SelectPopup> | |
| 247 | </Select> | |
| 248 | } | |
| 249 | /> | |
| 250 | ); | |
| 251 | ||
| 132 | 252 | return ( |
| 133 | 253 | <SettingsSection |
| 134 | 254 | id={ |
| 135 | category === "general" | |
| 255 | category === "general" || category === "project" | |
| 136 | 256 | ? "project-defaults" |
| 137 | 257 | : category === "integrations" |
| 138 | 258 | ? "browser-access" |
| 139 | 259 | : "source-control-defaults" |
| 140 | 260 | } |
| 141 | 261 | title={ |
| 142 | category === "general" | |
| 262 | category === "general" || category === "project" | |
| 143 | 263 | ? "New threads" |
| 144 | 264 | : category === "integrations" |
| 145 | 265 | ? "Browser" |
| 146 | 266 | : "Repositories" |
| 147 | 267 | } |
| 148 | 268 | > |
| 149 | {category === "general" ? ( | |
| 269 | {category === "project" ? ( | |
| 150 | 270 | <> |
| 151 | <SettingsRow | |
| 152 | serverScoped | |
| 153 | settingKeys={["defaultModelSelection"]} | |
| 154 | mixed={mixedModel} | |
| 155 | id="default-model" | |
| 156 | title="Model" | |
| 157 | description={ | |
| 158 | isProjectScope | |
| 159 | ? "Model for new threads in this project." | |
| 160 | : "Default model for new threads. Projects can override it." | |
| 161 | } | |
| 162 | status={ | |
| 163 | unavailable || mixedModel || modelSource === "project" | |
| 164 | ? undefined | |
| 165 | : settings.defaultModelSelection === null | |
| 166 | ? "Automatic" | |
| 167 | : undefined | |
| 168 | } | |
| 169 | resetAction={ | |
| 170 | settings.defaultModelSelection !== null ? ( | |
| 171 | <SettingResetButton label="default model" onClick={() => setModel(null)} /> | |
| 172 | ) : null | |
| 173 | } | |
| 174 | control={ | |
| 175 | selection && activeEntry ? ( | |
| 176 | <div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5"> | |
| 177 | <ProviderModelPicker | |
| 178 | activeInstanceId={selection.instanceId} | |
| 179 | model={selection.model} | |
| 180 | lockedProvider={null} | |
| 181 | instanceEntries={entries} | |
| 182 | modelOptionsByInstance={modelOptions} | |
| 183 | triggerVariant="outline" | |
| 184 | triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} | |
| 185 | {...(mixedModel ? { triggerLabel: "Mixed" } : {})} | |
| 186 | getModelDisabledReason={modelDisabledReason} | |
| 187 | onOpenProviderSetup={(instanceId) => { | |
| 188 | if (representative) | |
| 189 | void navigate({ | |
| 190 | to: "/settings/providers", | |
| 191 | search: { environmentId: representative.environmentId, instanceId }, | |
| 192 | }); | |
| 193 | }} | |
| 194 | onInstanceModelChange={(instanceId, model) => | |
| 195 | setModel(createModelSelection(instanceId, model)) | |
| 196 | } | |
| 197 | /> | |
| 198 | {!mixedModel ? ( | |
| 199 | <TraitsPicker | |
| 200 | provider={activeEntry.driverKind} | |
| 201 | models={activeEntry.models} | |
| 202 | model={selection.model} | |
| 203 | prompt="" | |
| 204 | onPromptChange={() => {}} | |
| 205 | modelOptions={selection.options ?? []} | |
| 206 | allowPromptInjectedEffort={false} | |
| 207 | planModeEnabled={settings.planModeEnabled} | |
| 208 | triggerVariant="outline" | |
| 209 | triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} | |
| 210 | onModelOptionsChange={(options) => | |
| 211 | setModel( | |
| 212 | createModelSelection(selection.instanceId, selection.model, options), | |
| 213 | ) | |
| 214 | } | |
| 215 | /> | |
| 216 | ) : null} | |
| 217 | </div> | |
| 218 | ) : ( | |
| 219 | <span className="text-sm text-muted-foreground">No providers available</span> | |
| 220 | ) | |
| 221 | } | |
| 222 | /> | |
| 271 | {modelRow} | |
| 272 | {workspaceRow} | |
| 273 | </> | |
| 274 | ) : category === "general" ? ( | |
| 275 | <> | |
| 276 | {modelRow} | |
| 223 | 277 | <SettingsRow |
| 224 | 278 | serverScoped |
| 225 | 279 | settingKeys={["defaultRuntimeMode"]} |
| ... | ... | @@ -281,51 +335,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting |
| 281 | 335 | </Select> |
| 282 | 336 | } |
| 283 | 337 | /> |
| 284 | <SettingsRow | |
| 285 | serverScoped | |
| 286 | settingKeys={["defaultThreadEnvMode"]} | |
| 287 | mixed={mixedWorkspace} | |
| 288 | id={searchableSetting("new-threads").id} | |
| 289 | title="Workspace" | |
| 290 | description={ | |
| 291 | isProjectScope | |
| 292 | ? "Where new threads in this project start." | |
| 293 | : "Where new threads start. Projects and their t3.json can override it." | |
| 294 | } | |
| 295 | resetAction={ | |
| 296 | !isProjectScope && settings.defaultThreadEnvMode !== null ? ( | |
| 297 | <SettingResetButton | |
| 298 | label="default workspace" | |
| 299 | onClick={() => updateSettings({ defaultThreadEnvMode: null })} | |
| 300 | /> | |
| 301 | ) : null | |
| 302 | } | |
| 303 | control={ | |
| 304 | <Select | |
| 305 | value={mixedWorkspace ? null : (effective?.defaultThreadEnvMode ?? null)} | |
| 306 | onValueChange={(value) => { | |
| 307 | if (value === "local" || value === "worktree") | |
| 308 | updateSettings({ defaultThreadEnvMode: value }); | |
| 309 | }} | |
| 310 | > | |
| 311 | <SelectTrigger size="sm" aria-label="Default workspace"> | |
| 312 | <SelectValue> | |
| 313 | {(value: string | null) => | |
| 314 | value === "local" || value === "worktree" | |
| 315 | ? resolveEnvModeLabel(value) | |
| 316 | : unavailable | |
| 317 | ? "Unavailable" | |
| 318 | : "Mixed" | |
| 319 | } | |
| 320 | </SelectValue> | |
| 321 | </SelectTrigger> | |
| 322 | <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 323 | <SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem> | |
| 324 | <SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem> | |
| 325 | </SelectPopup> | |
| 326 | </Select> | |
| 327 | } | |
| 328 | /> | |
| 338 | {workspaceRow} | |
| 329 | 339 | <SettingsRow |
| 330 | 340 | serverScoped |
| 331 | 341 | settingKeys={["worktreeSubmodules"]} |
apps/web/src/components/settings/ProjectSettingsPanel.tsx+12-2| ... | ... | @@ -10,7 +10,7 @@ import { AsyncResult } from "effect/unstable/reactivity"; |
| 10 | 10 | import { type EnvironmentId, type ProjectIconOverride } from "@t3tools/contracts"; |
| 11 | 11 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 12 | 12 | import * as Cause from "effect/Cause"; |
| 13 | import { Trash2Icon } from "lucide-react"; | |
| 13 | import { InfoIcon, Trash2Icon } from "lucide-react"; | |
| 14 | 14 | import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 15 | 15 | |
| 16 | 16 | import { useComposerDraftStore } from "../../composerDraftStore"; |
| ... | ... | @@ -25,6 +25,7 @@ import { useThreadShells } from "../../state/entities"; |
| 25 | 25 | import { projectEnvironment } from "../../state/projects"; |
| 26 | 26 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 27 | 27 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 28 | import { Alert, AlertDescription } from "../ui/alert"; | |
| 28 | 29 | import { Button } from "../ui/button"; |
| 29 | 30 | import { Input } from "../ui/input"; |
| 30 | 31 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| ... | ... | @@ -39,6 +40,7 @@ import { |
| 39 | 40 | ProjectFaviconPickerDialog, |
| 40 | 41 | } from "./ProjectFaviconPickerDialog"; |
| 41 | 42 | import { ProjectActionsSettings } from "./ProjectActionsSettings"; |
| 43 | import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; | |
| 42 | 44 | import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; |
| 43 | 45 | import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; |
| 44 | 46 | |
| ... | ... | @@ -52,7 +54,8 @@ function memberKey(member: { environmentId: string; id: string }): string { |
| 52 | 54 | return `${member.environmentId}:${member.id}`; |
| 53 | 55 | } |
| 54 | 56 | |
| 55 | export type ProjectSettingsCategory = "general" | "integrations" | "source-control"; | |
| 57 | /** `project` is the Projects page shortcut: the new-thread defaults people change most. */ | |
| 58 | export type ProjectSettingsCategory = "general" | "integrations" | "source-control" | "project"; | |
| 56 | 59 | |
| 57 | 60 | export function ProjectSettingsPanel({ |
| 58 | 61 | projectKey, |
| ... | ... | @@ -406,6 +409,12 @@ function ProjectDetail({ |
| 406 | 409 | return ( |
| 407 | 410 | <> |
| 408 | 411 | <SettingsPageContainer className="gap-6"> |
| 412 | <Alert variant="info"> | |
| 413 | <InfoIcon aria-hidden /> | |
| 414 | <AlertDescription> | |
| 415 | Can't find a setting? Keep this project picked above and hop to any other settings page. | |
| 416 | </AlertDescription> | |
| 417 | </Alert> | |
| 409 | 418 | <SettingsSection id="project-overview" title="Project" hideTitle> |
| 410 | 419 | <SettingsRow |
| 411 | 420 | title="Name" |
| ... | ... | @@ -480,6 +489,7 @@ function ProjectDetail({ |
| 480 | 489 | } |
| 481 | 490 | /> |
| 482 | 491 | </SettingsSection> |
| 492 | <ProjectDefaultsSettings category="project" /> | |
| 483 | 493 | <ProjectActionsSettings /> |
| 484 | 494 | {hasMultipleCheckouts ? checkoutChoices : null} |
| 485 | 495 | <SettingsSection title="Danger"> |
apps/web/src/components/settings/ProjectsSettings.tsx+4-1| ... | ... | @@ -3,6 +3,7 @@ import { EnvironmentId } from "@t3tools/contracts"; |
| 3 | 3 | import { ProjectSettingsPanel } from "./ProjectSettingsPanel"; |
| 4 | 4 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 5 | 5 | import { SettingsScopeNotice } from "./SettingsScopeNotice"; |
| 6 | import { SettingsPageContainer } from "./settingsLayout"; | |
| 6 | 7 | |
| 7 | 8 | /** Project identity and checkout management for the selected project. */ |
| 8 | 9 | export function ProjectsSettings() { |
| ... | ... | @@ -22,7 +23,9 @@ export function ProjectsSettings() { |
| 22 | 23 | checkoutKey={value.checkout ?? null} |
| 23 | 24 | /> |
| 24 | 25 | ) : scope.kind === "unavailable" ? ( |
| 25 | <p className="p-8 text-sm text-muted-foreground">{scope.message}</p> | |
| 26 | <SettingsPageContainer> | |
| 27 | <p className="text-sm text-muted-foreground">{scope.message}</p> | |
| 28 | </SettingsPageContainer> | |
| 26 | 29 | ) : ( |
| 27 | 30 | <SettingsScopeNotice target="project"> |
| 28 | 31 | Choose a project to manage its name, icon, checkouts and actions. |
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+1| ... | ... | @@ -58,6 +58,7 @@ vi.mock("./settingsLayout", async (importOriginal) => { |
| 58 | 58 | }; |
| 59 | 59 | }); |
| 60 | 60 | |
| 61 | vi.mock("./SettingsScopeSentence", () => ({ SettingsScopeSentence: () => null })); | |
| 61 | 62 | vi.mock("react/compiler-runtime", async () => { |
| 62 | 63 | const { reactHookHarness } = await import("../../test/reactHookHarness"); |
| 63 | 64 | return { c: reactHookHarness.useMemoCache }; |
apps/web/src/components/settings/SettingsBreadcrumb.tsx+3-195| ... | ... | @@ -1,37 +1,9 @@ |
| 1 | import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; | |
| 2 | import { LayersIcon } from "lucide-react"; | |
| 3 | import type { ReactNode } from "react"; | |
| 4 | ||
| 5 | import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; | |
| 6 | import type { EnvironmentPresentation } from "../../state/environments"; | |
| 7 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; | |
| 8 | import { ProjectFavicon } from "../ProjectFavicon"; | |
| 9 | import { InlineButton } from "../ui/button"; | |
| 10 | import { | |
| 11 | Menu, | |
| 12 | MenuPopup, | |
| 13 | MenuRadioGroup, | |
| 14 | MenuRadioItem, | |
| 15 | MenuRadioItemIndicator, | |
| 16 | MenuSeparator, | |
| 17 | MenuTrigger, | |
| 18 | } from "../ui/menu"; | |
| 19 | 1 | import { |
| 20 | 2 | WorkspaceBreadcrumb, |
| 21 | 3 | WorkspaceBreadcrumbItem, |
| 22 | 4 | WorkspaceBreadcrumbSeparator, |
| 23 | 5 | } from "../WorkspaceBreadcrumb"; |
| 24 | 6 | import { SETTINGS_SECTION_LABELS } from "./settingsSearch"; |
| 25 | import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; | |
| 26 | import { | |
| 27 | ALL_ENVIRONMENTS_VALUE, | |
| 28 | ALL_PROJECTS_VALUE, | |
| 29 | environmentAxisValue, | |
| 30 | projectAxisValue, | |
| 31 | selectEnvironmentAxis, | |
| 32 | selectProjectAxis, | |
| 33 | settingsScopeEnvironmentLabel, | |
| 34 | } from "./settingsScopeAxis"; | |
| 35 | 7 | |
| 36 | 8 | const SETTINGS_BREADCRUMB_LABELS: Readonly<Record<string, string>> = { |
| 37 | 9 | ...SETTINGS_SECTION_LABELS, |
| ... | ... | @@ -44,27 +16,11 @@ function settingsBreadcrumbLabel(pathname: string): string | null { |
| 44 | 16 | return SETTINGS_BREADCRUMB_LABELS[normalizedPathname] ?? null; |
| 45 | 17 | } |
| 46 | 18 | |
| 47 | export interface SettingsScopeBreadcrumbProps { | |
| 48 | readonly value: SettingsScopeSearch; | |
| 49 | readonly groups: readonly SidebarProjectSnapshot[]; | |
| 50 | readonly environments: readonly EnvironmentPresentation[]; | |
| 51 | readonly onChange: (next: SettingsScopeSearch) => void; | |
| 52 | } | |
| 53 | ||
| 54 | 19 | /** |
| 55 | * `Settings / Section / Environment / Project`. The last two crumbs are the | |
| 56 | * targets a change applies to and read like the usage page's filter: muted at | |
| 57 | * "all", foreground once narrowed. A project is the same project on every | |
| 58 | * environment, so the environment crumb alone decides where a project | |
| 59 | * override is written. | |
| 20 | * `Settings / Section`. The scope a change applies to lives at the top of the | |
| 21 | * page content, see `SettingsScopeSentence`. | |
| 60 | 22 | */ |
| 61 | export function SettingsBreadcrumb({ | |
| 62 | pathname, | |
| 63 | scope, | |
| 64 | }: { | |
| 65 | pathname: string; | |
| 66 | scope?: SettingsScopeBreadcrumbProps | undefined; | |
| 67 | }) { | |
| 23 | export function SettingsBreadcrumb({ pathname }: { pathname: string }) { | |
| 68 | 24 | const sectionLabel = settingsBreadcrumbLabel(pathname); |
| 69 | 25 | |
| 70 | 26 | return ( |
| ... | ... | @@ -78,154 +34,6 @@ export function SettingsBreadcrumb({ |
| 78 | 34 | <WorkspaceBreadcrumbItem current className="truncate"> |
| 79 | 35 | {sectionLabel ?? "Settings"} |
| 80 | 36 | </WorkspaceBreadcrumbItem> |
| 81 | {scope ? ( | |
| 82 | <> | |
| 83 | <WorkspaceBreadcrumbSeparator /> | |
| 84 | <WorkspaceBreadcrumbItem className="min-w-0 shrink"> | |
| 85 | <EnvironmentScopeMenu {...scope} /> | |
| 86 | </WorkspaceBreadcrumbItem> | |
| 87 | <WorkspaceBreadcrumbSeparator /> | |
| 88 | <WorkspaceBreadcrumbItem className="min-w-0 shrink"> | |
| 89 | <ProjectScopeMenu {...scope} /> | |
| 90 | </WorkspaceBreadcrumbItem> | |
| 91 | </> | |
| 92 | ) : null} | |
| 93 | 37 | </WorkspaceBreadcrumb> |
| 94 | 38 | ); |
| 95 | 39 | } |
| 96 | ||
| 97 | function ScopeMenu({ | |
| 98 | ariaLabel, | |
| 99 | icon, | |
| 100 | label, | |
| 101 | narrowed, | |
| 102 | children, | |
| 103 | }: { | |
| 104 | ariaLabel: string; | |
| 105 | icon: ReactNode; | |
| 106 | label: string; | |
| 107 | narrowed: boolean; | |
| 108 | children: ReactNode; | |
| 109 | }) { | |
| 110 | return ( | |
| 111 | <Menu> | |
| 112 | <MenuTrigger | |
| 113 | aria-label={ariaLabel} | |
| 114 | render={<InlineButton tone={narrowed ? "default" : "muted"} />} | |
| 115 | className="min-w-0 max-w-56 gap-1.5" | |
| 116 | > | |
| 117 | {icon} | |
| 118 | <span className="min-w-0 truncate">{label}</span> | |
| 119 | </MenuTrigger> | |
| 120 | <MenuPopup align="start">{children}</MenuPopup> | |
| 121 | </Menu> | |
| 122 | ); | |
| 123 | } | |
| 124 | ||
| 125 | function EnvironmentScopeMenu({ | |
| 126 | value, | |
| 127 | groups, | |
| 128 | environments, | |
| 129 | onChange, | |
| 130 | }: SettingsScopeBreadcrumbProps) { | |
| 131 | const resolved = resolveSettingsScope(value, groups, environments); | |
| 132 | const environmentValue = environmentAxisValue( | |
| 133 | value, | |
| 134 | resolved.kind === "checkout" ? resolved.environmentId : null, | |
| 135 | ); | |
| 136 | const selected = environments.find( | |
| 137 | (environment) => environment.environmentId === environmentValue, | |
| 138 | ); | |
| 139 | return ( | |
| 140 | <ScopeMenu | |
| 141 | ariaLabel="Environment scope" | |
| 142 | narrowed={environmentValue !== ALL_ENVIRONMENTS_VALUE} | |
| 143 | icon={ | |
| 144 | selected ? ( | |
| 145 | <EnvironmentMachineIcon | |
| 146 | aria-hidden | |
| 147 | kind={resolveEnvironmentMachineKind(selected.serverConfig)} | |
| 148 | className="size-3.5 shrink-0" | |
| 149 | /> | |
| 150 | ) : null | |
| 151 | } | |
| 152 | label={ | |
| 153 | selected | |
| 154 | ? settingsScopeEnvironmentLabel(selected, environments) | |
| 155 | : environmentValue !== ALL_ENVIRONMENTS_VALUE | |
| 156 | ? "Unavailable environment" | |
| 157 | : "All environments" | |
| 158 | } | |
| 159 | > | |
| 160 | <MenuRadioGroup | |
| 161 | value={environmentValue} | |
| 162 | onValueChange={(next) => { | |
| 163 | if (typeof next === "string") onChange(selectEnvironmentAxis(value, next)); | |
| 164 | }} | |
| 165 | > | |
| 166 | <MenuRadioItem value={ALL_ENVIRONMENTS_VALUE}> | |
| 167 | <span className="flex min-w-0 items-center gap-2"> | |
| 168 | <LayersIcon aria-hidden className="size-3.5" /> | |
| 169 | <span className="min-w-0 flex-1 truncate">All environments</span> | |
| 170 | <MenuRadioItemIndicator /> | |
| 171 | </span> | |
| 172 | </MenuRadioItem> | |
| 173 | <MenuSeparator /> | |
| 174 | {environments.map((environment) => ( | |
| 175 | <MenuRadioItem key={environment.environmentId} value={environment.environmentId}> | |
| 176 | <span className="flex min-w-0 items-center gap-2"> | |
| 177 | <EnvironmentMachineIcon | |
| 178 | aria-hidden | |
| 179 | kind={resolveEnvironmentMachineKind(environment.serverConfig)} | |
| 180 | className="size-3.5" | |
| 181 | /> | |
| 182 | <span className="min-w-0 flex-1 truncate"> | |
| 183 | {settingsScopeEnvironmentLabel(environment, environments)} | |
| 184 | </span> | |
| 185 | {environment.connection.phase === "connected" ? null : ( | |
| 186 | <span className="shrink-0 text-xs text-muted-foreground">Offline</span> | |
| 187 | )} | |
| 188 | <MenuRadioItemIndicator /> | |
| 189 | </span> | |
| 190 | </MenuRadioItem> | |
| 191 | ))} | |
| 192 | </MenuRadioGroup> | |
| 193 | </ScopeMenu> | |
| 194 | ); | |
| 195 | } | |
| 196 | ||
| 197 | function ProjectScopeMenu({ value, groups, onChange }: SettingsScopeBreadcrumbProps) { | |
| 198 | const selected = groups.find((group) => group.projectKey === value.project); | |
| 199 | return ( | |
| 200 | <ScopeMenu | |
| 201 | ariaLabel="Project scope" | |
| 202 | narrowed={value.project !== undefined} | |
| 203 | icon={selected ? <ProjectFavicon project={selected} className="size-3.5 shrink-0" /> : null} | |
| 204 | label={selected?.displayName ?? (value.project ? "Unavailable project" : "All projects")} | |
| 205 | > | |
| 206 | <MenuRadioGroup | |
| 207 | value={projectAxisValue(value)} | |
| 208 | onValueChange={(next) => { | |
| 209 | if (typeof next === "string") onChange(selectProjectAxis(value, next)); | |
| 210 | }} | |
| 211 | > | |
| 212 | <MenuRadioItem value={ALL_PROJECTS_VALUE}> | |
| 213 | <span className="flex min-w-0 items-center gap-2"> | |
| 214 | <span className="min-w-0 flex-1 truncate">All projects</span> | |
| 215 | <MenuRadioItemIndicator /> | |
| 216 | </span> | |
| 217 | </MenuRadioItem> | |
| 218 | <MenuSeparator /> | |
| 219 | {groups.map((group) => ( | |
| 220 | <MenuRadioItem key={group.projectKey} value={group.projectKey}> | |
| 221 | <span className="flex min-w-0 items-center gap-2"> | |
| 222 | <ProjectFavicon project={group} className="size-3.5" /> | |
| 223 | <span className="min-w-0 flex-1 truncate">{group.displayName}</span> | |
| 224 | <MenuRadioItemIndicator /> | |
| 225 | </span> | |
| 226 | </MenuRadioItem> | |
| 227 | ))} | |
| 228 | </MenuRadioGroup> | |
| 229 | </ScopeMenu> | |
| 230 | ); | |
| 231 | } |
apps/web/src/components/settings/SettingsScopeSentence.tsx created+207| ... | ... | @@ -0,0 +1,207 @@ |
| 1 | import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; | |
| 2 | import { useLocation } from "@tanstack/react-router"; | |
| 3 | import { ChevronDownIcon, LayersIcon } from "lucide-react"; | |
| 4 | import type { ReactNode } from "react"; | |
| 5 | ||
| 6 | import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; | |
| 7 | import { useEnvironments, type EnvironmentPresentation } from "../../state/environments"; | |
| 8 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; | |
| 9 | import { ProjectFavicon } from "../ProjectFavicon"; | |
| 10 | import { InlineButton } from "../ui/button"; | |
| 11 | import { | |
| 12 | Menu, | |
| 13 | MenuPopup, | |
| 14 | MenuRadioGroup, | |
| 15 | MenuRadioItem, | |
| 16 | MenuRadioItemIndicator, | |
| 17 | MenuSeparator, | |
| 18 | MenuTrigger, | |
| 19 | } from "../ui/menu"; | |
| 20 | import { useOptionalSettingsScope } from "./SettingsScopeContext"; | |
| 21 | import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; | |
| 22 | import { | |
| 23 | ALL_ENVIRONMENTS_VALUE, | |
| 24 | ALL_PROJECTS_VALUE, | |
| 25 | environmentAxisValue, | |
| 26 | projectAxisValue, | |
| 27 | selectEnvironmentAxis, | |
| 28 | selectProjectAxis, | |
| 29 | settingsScopeEnvironmentLabel, | |
| 30 | } from "./settingsScopeAxis"; | |
| 31 | ||
| 32 | /** Pages whose every row is saved on this client; they have no scope to pick. */ | |
| 33 | export const SETTINGS_DEVICE_ONLY_PATHS: ReadonlySet<string> = new Set([ | |
| 34 | "/settings/appearance", | |
| 35 | "/settings/snap-shot", | |
| 36 | "/settings/connections", | |
| 37 | ]); | |
| 38 | ||
| 39 | interface SettingsScopeMenuProps { | |
| 40 | readonly value: SettingsScopeSearch; | |
| 41 | readonly groups: readonly SidebarProjectSnapshot[]; | |
| 42 | readonly environments: readonly EnvironmentPresentation[]; | |
| 43 | readonly onChange: (next: SettingsScopeSearch) => void; | |
| 44 | } | |
| 45 | ||
| 46 | /** | |
| 47 | * "Applying settings for <project> across <environment>" at the top of a settings | |
| 48 | * page. The two pickers are the targets a change is written to. A project is | |
| 49 | * the same project on every environment, so the environment alone decides | |
| 50 | * where a project override is written. | |
| 51 | */ | |
| 52 | export function SettingsScopeSentence() { | |
| 53 | const scope = useOptionalSettingsScope(); | |
| 54 | const pathname = useLocation({ select: (location) => location.pathname }); | |
| 55 | const { environments } = useEnvironments(); | |
| 56 | if (scope === null || SETTINGS_DEVICE_ONLY_PATHS.has(pathname)) return null; | |
| 57 | const props: SettingsScopeMenuProps = { | |
| 58 | value: scope.search, | |
| 59 | groups: scope.groups, | |
| 60 | environments, | |
| 61 | onChange: scope.selectScope, | |
| 62 | }; | |
| 63 | return ( | |
| 64 | <p className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 px-3 text-base text-muted-foreground sm:px-4"> | |
| 65 | {/* Each connective stays with its picker so a wrap never strands "on". */} | |
| 66 | <span className="flex min-w-0 items-center gap-1.5"> | |
| 67 | <span className="shrink-0">Applying settings for</span> | |
| 68 | <ProjectScopeMenu {...props} /> | |
| 69 | </span> | |
| 70 | <span className="flex min-w-0 items-center gap-1.5"> | |
| 71 | <span className="shrink-0"> | |
| 72 | {/* A legacy checkout link names one environment without `machine`. */} | |
| 73 | {scope.search.machine || scope.scope.kind === "checkout" ? "on" : "across"} | |
| 74 | </span> | |
| 75 | <EnvironmentScopeMenu {...props} /> | |
| 76 | </span> | |
| 77 | </p> | |
| 78 | ); | |
| 79 | } | |
| 80 | ||
| 81 | function ScopeMenu({ | |
| 82 | ariaLabel, | |
| 83 | icon, | |
| 84 | label, | |
| 85 | children, | |
| 86 | }: { | |
| 87 | ariaLabel: string; | |
| 88 | icon: ReactNode; | |
| 89 | label: string; | |
| 90 | children: ReactNode; | |
| 91 | }) { | |
| 92 | return ( | |
| 93 | <Menu> | |
| 94 | <MenuTrigger | |
| 95 | aria-label={`${ariaLabel}: ${label}`} | |
| 96 | render={<InlineButton tone="picker" />} | |
| 97 | className="min-w-0 max-w-72" | |
| 98 | > | |
| 99 | {icon} | |
| 100 | <span className="min-w-0 truncate">{label}</span> | |
| 101 | <ChevronDownIcon aria-hidden className="size-3.5 shrink-0 text-muted-foreground" /> | |
| 102 | </MenuTrigger> | |
| 103 | <MenuPopup align="start">{children}</MenuPopup> | |
| 104 | </Menu> | |
| 105 | ); | |
| 106 | } | |
| 107 | ||
| 108 | function EnvironmentScopeMenu({ value, groups, environments, onChange }: SettingsScopeMenuProps) { | |
| 109 | const resolved = resolveSettingsScope(value, groups, environments); | |
| 110 | const environmentValue = environmentAxisValue( | |
| 111 | value, | |
| 112 | resolved.kind === "checkout" ? resolved.environmentId : null, | |
| 113 | ); | |
| 114 | const selected = environments.find( | |
| 115 | (environment) => environment.environmentId === environmentValue, | |
| 116 | ); | |
| 117 | return ( | |
| 118 | <ScopeMenu | |
| 119 | ariaLabel="Environment scope" | |
| 120 | icon={ | |
| 121 | selected ? ( | |
| 122 | <EnvironmentMachineIcon | |
| 123 | aria-hidden | |
| 124 | kind={resolveEnvironmentMachineKind(selected.serverConfig)} | |
| 125 | className="size-3.5 shrink-0" | |
| 126 | /> | |
| 127 | ) : null | |
| 128 | } | |
| 129 | label={ | |
| 130 | selected | |
| 131 | ? settingsScopeEnvironmentLabel(selected, environments) | |
| 132 | : environmentValue !== ALL_ENVIRONMENTS_VALUE | |
| 133 | ? "Unavailable environment" | |
| 134 | : "All environments" | |
| 135 | } | |
| 136 | > | |
| 137 | <MenuRadioGroup | |
| 138 | value={environmentValue} | |
| 139 | onValueChange={(next) => { | |
| 140 | if (typeof next === "string") onChange(selectEnvironmentAxis(value, next)); | |
| 141 | }} | |
| 142 | > | |
| 143 | <MenuRadioItem value={ALL_ENVIRONMENTS_VALUE}> | |
| 144 | <span className="flex min-w-0 items-center gap-2"> | |
| 145 | <LayersIcon aria-hidden className="size-3.5" /> | |
| 146 | <span className="min-w-0 flex-1 truncate">All environments</span> | |
| 147 | <MenuRadioItemIndicator /> | |
| 148 | </span> | |
| 149 | </MenuRadioItem> | |
| 150 | <MenuSeparator /> | |
| 151 | {environments.map((environment) => ( | |
| 152 | <MenuRadioItem key={environment.environmentId} value={environment.environmentId}> | |
| 153 | <span className="flex min-w-0 items-center gap-2"> | |
| 154 | <EnvironmentMachineIcon | |
| 155 | aria-hidden | |
| 156 | kind={resolveEnvironmentMachineKind(environment.serverConfig)} | |
| 157 | className="size-3.5" | |
| 158 | /> | |
| 159 | <span className="min-w-0 flex-1 truncate"> | |
| 160 | {settingsScopeEnvironmentLabel(environment, environments)} | |
| 161 | </span> | |
| 162 | {environment.connection.phase === "connected" ? null : ( | |
| 163 | <span className="shrink-0 text-xs text-muted-foreground">Offline</span> | |
| 164 | )} | |
| 165 | <MenuRadioItemIndicator /> | |
| 166 | </span> | |
| 167 | </MenuRadioItem> | |
| 168 | ))} | |
| 169 | </MenuRadioGroup> | |
| 170 | </ScopeMenu> | |
| 171 | ); | |
| 172 | } | |
| 173 | ||
| 174 | function ProjectScopeMenu({ value, groups, onChange }: SettingsScopeMenuProps) { | |
| 175 | const selected = groups.find((group) => group.projectKey === value.project); | |
| 176 | return ( | |
| 177 | <ScopeMenu | |
| 178 | ariaLabel="Project scope" | |
| 179 | icon={selected ? <ProjectFavicon project={selected} className="size-3.5 shrink-0" /> : null} | |
| 180 | label={selected?.displayName ?? (value.project ? "Unavailable project" : "All projects")} | |
| 181 | > | |
| 182 | <MenuRadioGroup | |
| 183 | value={projectAxisValue(value)} | |
| 184 | onValueChange={(next) => { | |
| 185 | if (typeof next === "string") onChange(selectProjectAxis(value, next)); | |
| 186 | }} | |
| 187 | > | |
| 188 | <MenuRadioItem value={ALL_PROJECTS_VALUE}> | |
| 189 | <span className="flex min-w-0 items-center gap-2"> | |
| 190 | <span className="min-w-0 flex-1 truncate">All projects</span> | |
| 191 | <MenuRadioItemIndicator /> | |
| 192 | </span> | |
| 193 | </MenuRadioItem> | |
| 194 | <MenuSeparator /> | |
| 195 | {groups.map((group) => ( | |
| 196 | <MenuRadioItem key={group.projectKey} value={group.projectKey}> | |
| 197 | <span className="flex min-w-0 items-center gap-2"> | |
| 198 | <ProjectFavicon project={group} className="size-3.5" /> | |
| 199 | <span className="min-w-0 flex-1 truncate">{group.displayName}</span> | |
| 200 | <MenuRadioItemIndicator /> | |
| 201 | </span> | |
| 202 | </MenuRadioItem> | |
| 203 | ))} | |
| 204 | </MenuRadioGroup> | |
| 205 | </ScopeMenu> | |
| 206 | ); | |
| 207 | } |
apps/web/src/components/settings/SnapShotSettings.test.tsx+1| ... | ... | @@ -27,6 +27,7 @@ vi.mock("react/compiler-runtime", async () => { |
| 27 | 27 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); |
| 28 | 28 | vi.mock("../../state/server", () => ({ primaryServerKeybindingsAtom: {} })); |
| 29 | 29 | vi.mock("./SettingsScopeContext", () => ({ useOptionalSettingsScope: () => null })); |
| 30 | vi.mock("./SettingsScopeSentence", () => ({ SettingsScopeSentence: () => null })); | |
| 30 | 31 | const bridge = vi.hoisted(() => ({ |
| 31 | 32 | getSnapShotState: vi.fn<() => Promise<DesktopSnapShotState>>(), |
| 32 | 33 | setSnapShotShortcutSuppressed: vi.fn(), |
apps/web/src/components/settings/settingsLayout.tsx+2| ... | ... | @@ -23,6 +23,7 @@ import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePag |
| 23 | 23 | import { Button } from "../ui/button"; |
| 24 | 24 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 25 | 25 | import { useOptionalSettingsScope } from "./SettingsScopeContext"; |
| 26 | import { SettingsScopeSentence } from "./SettingsScopeSentence"; | |
| 26 | 27 | import { |
| 27 | 28 | isProjectScopedSettingKey, |
| 28 | 29 | listProjectOverrides, |
| ... | ... | @@ -545,6 +546,7 @@ export function SettingsPageContainer({ |
| 545 | 546 | data-settings-page-scroll |
| 546 | 547 | > |
| 547 | 548 | <WorkspacePageContainer width={width} className={cn("gap-8", className)}> |
| 549 | <SettingsScopeSentence /> | |
| 548 | 550 | {children} |
| 549 | 551 | </WorkspacePageContainer> |
| 550 | 552 | </div> |
apps/web/src/components/ui/button.tsx+3| ... | ... | @@ -106,6 +106,9 @@ const inlineButtonVariants = cva( |
| 106 | 106 | default: "text-foreground", |
| 107 | 107 | muted: "text-muted-foreground hover:text-foreground", |
| 108 | 108 | destructive: "text-destructive/80 hover:text-destructive", |
| 109 | /** Opens a menu from inside a sentence; the dotted underline marks it as a choice. */ | |
| 110 | picker: | |
| 111 | "gap-1.5 text-foreground underline decoration-foreground/30 decoration-dotted decoration-from-font underline-offset-4 hover:decoration-foreground hover:decoration-solid data-popup-open:decoration-foreground data-popup-open:decoration-solid", | |
| 109 | 112 | }, |
| 110 | 113 | }, |
| 111 | 114 | }, |
apps/web/src/routes/settings.tsx+16-25| ... | ... | @@ -19,9 +19,10 @@ import { |
| 19 | 19 | SettingsScopeProvider, |
| 20 | 20 | useSettingsScope, |
| 21 | 21 | } from "../components/settings/SettingsScopeContext"; |
| 22 | import { useSettingsProjectGroups } from "../components/settings/useSettingsProjectGroups"; | |
| 23 | 22 | import { useEnvironments } from "../state/environments"; |
| 24 | 23 | import { SettingsScopeNotice } from "../components/settings/SettingsScopeNotice"; |
| 24 | import { SETTINGS_DEVICE_ONLY_PATHS } from "../components/settings/SettingsScopeSentence"; | |
| 25 | import { SettingsPageContainer } from "../components/settings/settingsLayout"; | |
| 25 | 26 | import { |
| 26 | 27 | retainSettingsScope, |
| 27 | 28 | validateSettingsRouteSearch, |
| ... | ... | @@ -47,13 +48,6 @@ function RestoreDeviceDefaultsButton({ onRestored }: { onRestored: () => void }) |
| 47 | 48 | ); |
| 48 | 49 | } |
| 49 | 50 | |
| 50 | /** Pages whose every row is saved on this client; the scope selects are hidden there. */ | |
| 51 | const DEVICE_ONLY_PATHS = new Set([ | |
| 52 | "/settings/appearance", | |
| 53 | "/settings/snap-shot", | |
| 54 | "/settings/connections", | |
| 55 | ]); | |
| 56 | ||
| 57 | 51 | function SettingsScopeBoundary({ pathname, children }: { pathname: string; children: ReactNode }) { |
| 58 | 52 | const { scope, connectedEnvironments } = useSettingsScope(); |
| 59 | 53 | const { environments } = useEnvironments(); |
| ... | ... | @@ -99,16 +93,23 @@ function SettingsScopeBoundary({ pathname, children }: { pathname: string; child |
| 99 | 93 | } |
| 100 | 94 | // Device-local pages ignore the scope entirely; the project page follows |
| 101 | 95 | // remembered members while a grouping change replaces its URL key. |
| 102 | if (DEVICE_ONLY_PATHS.has(pathname) || pathname === "/settings/projects") { | |
| 96 | if (SETTINGS_DEVICE_ONLY_PATHS.has(pathname) || pathname === "/settings/projects") { | |
| 103 | 97 | return children; |
| 104 | 98 | } |
| 99 | // Keep the scope sentence on screen so the selection can be changed back. | |
| 105 | 100 | if (scope.kind === "unavailable") |
| 106 | return <p className="p-8 text-sm text-muted-foreground">{scope.message}</p>; | |
| 101 | return ( | |
| 102 | <SettingsPageContainer> | |
| 103 | <p className="text-sm text-muted-foreground">{scope.message}</p> | |
| 104 | </SettingsPageContainer> | |
| 105 | ); | |
| 107 | 106 | if (scope.kind === "environment" && connectedEnvironments.length === 0) { |
| 108 | 107 | return ( |
| 109 | <p className="p-8 text-sm text-muted-foreground"> | |
| 110 | Reconnect {scope.label} to change its settings. | |
| 111 | </p> | |
| 108 | <SettingsPageContainer> | |
| 109 | <p className="text-sm text-muted-foreground"> | |
| 110 | Reconnect {scope.label} to change its settings. | |
| 111 | </p> | |
| 112 | </SettingsPageContainer> | |
| 112 | 113 | ); |
| 113 | 114 | } |
| 114 | 115 | return children; |
| ... | ... | @@ -118,11 +119,8 @@ function SettingsContentLayout() { |
| 118 | 119 | const location = useLocation(); |
| 119 | 120 | const navigate = useNavigate(); |
| 120 | 121 | const canGoBack = useCanGoBack(); |
| 121 | const { search, selectScope } = useSettingsScope(); | |
| 122 | const groups = useSettingsProjectGroups(); | |
| 123 | const { environments } = useEnvironments(); | |
| 122 | const { search } = useSettingsScope(); | |
| 124 | 123 | const [restoreSignal, setRestoreSignal] = useState(0); |
| 125 | const showScope = !DEVICE_ONLY_PATHS.has(location.pathname); | |
| 126 | 124 | const navigateBackWithinApp = useCallback(() => { |
| 127 | 125 | if (canGoBack) { |
| 128 | 126 | window.history.back(); |
| ... | ... | @@ -157,14 +155,7 @@ function SettingsContentLayout() { |
| 157 | 155 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> |
| 158 | 156 | <WorkspacePageHeader electron={isElectron}> |
| 159 | 157 | <div className="flex w-full items-center gap-3"> |
| 160 | <SettingsBreadcrumb | |
| 161 | pathname={location.pathname} | |
| 162 | scope={ | |
| 163 | showScope | |
| 164 | ? { value: search, groups, environments, onChange: selectScope } | |
| 165 | : undefined | |
| 166 | } | |
| 167 | /> | |
| 158 | <SettingsBreadcrumb pathname={location.pathname} /> | |
| 168 | 159 | {location.pathname === "/settings/general" ? ( |
| 169 | 160 | <div className="ms-auto flex shrink-0 items-center"> |
| 170 | 161 | <RestoreDeviceDefaultsButton |
docs/user/project-settings.md+4-3| ... | ... | @@ -1,8 +1,9 @@ |
| 1 | 1 | # Settings and project overrides |
| 2 | 2 | |
| 3 | On web and desktop, the Settings breadcrumb ends with the environment and project a change applies to. They start | |
| 4 | at **All environments** and **All projects** and stay selected as you move between categories or | |
| 5 | search for a setting. | |
| 3 | On web and desktop, the "Applying settings for …" sentence at the top of Settings pages picks | |
| 4 | the project and environment a change applies to. Pages that only hold device preferences, such as | |
| 5 | Appearance, don't show it. They start at **All projects** and **All environments** | |
| 6 | and stay selected as you move between categories or search for a setting. | |
| 6 | 7 | |
| 7 | 8 | Preferences saved on this device, such as appearance, confirmations and browser profiles, always |
| 8 | 9 | show and ignore the selection. Everything else is stored on a server. Choose one environment to |