authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 16:46:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 23:46:27+00:00
log242816af805b048aec653d6cc359ecc784ef98c5
tree01638c19343bd019e9a2ec1a123e9b28aa159308
parentbed0b4cdad41408bb09b445a0a8f8ea0e5b62d1b
signature Signed by PGP key B5690EEEBB952194

feat(web): show settings scope as a sentence at the top of the page (#13139)

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
129129 updateSettings({ defaultModelSelection: value });
130130 };
131131
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
132252 return (
133253 <SettingsSection
134254 id={
135 category === "general"
255 category === "general" || category === "project"
136256 ? "project-defaults"
137257 : category === "integrations"
138258 ? "browser-access"
139259 : "source-control-defaults"
140260 }
141261 title={
142 category === "general"
262 category === "general" || category === "project"
143263 ? "New threads"
144264 : category === "integrations"
145265 ? "Browser"
146266 : "Repositories"
147267 }
148268 >
149 {category === "general" ? (
269 {category === "project" ? (
150270 <>
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}
223277 <SettingsRow
224278 serverScoped
225279 settingKeys={["defaultRuntimeMode"]}
......@@ -281,51 +335,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
281335 </Select>
282336 }
283337 />
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}
329339 <SettingsRow
330340 serverScoped
331341 settingKeys={["worktreeSubmodules"]}
apps/web/src/components/settings/ProjectSettingsPanel.tsx+12-2
......@@ -10,7 +10,7 @@ import { AsyncResult } from "effect/unstable/reactivity";
1010import { type EnvironmentId, type ProjectIconOverride } from "@t3tools/contracts";
1111import { useLocation, useNavigate } from "@tanstack/react-router";
1212import * as Cause from "effect/Cause";
13import { Trash2Icon } from "lucide-react";
13import { InfoIcon, Trash2Icon } from "lucide-react";
1414import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
1515
1616import { useComposerDraftStore } from "../../composerDraftStore";
......@@ -25,6 +25,7 @@ import { useThreadShells } from "../../state/entities";
2525import { projectEnvironment } from "../../state/projects";
2626import { useAtomCommand } from "../../state/use-atom-command";
2727import { ProjectFavicon } from "../ProjectFavicon";
28import { Alert, AlertDescription } from "../ui/alert";
2829import { Button } from "../ui/button";
2930import { Input } from "../ui/input";
3031import { stackedThreadToast, toastManager } from "../ui/toast";
......@@ -39,6 +40,7 @@ import {
3940 ProjectFaviconPickerDialog,
4041} from "./ProjectFaviconPickerDialog";
4142import { ProjectActionsSettings } from "./ProjectActionsSettings";
43import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
4244import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";
4345import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
4446
......@@ -52,7 +54,8 @@ function memberKey(member: { environmentId: string; id: string }): string {
5254 return `${member.environmentId}:${member.id}`;
5355}
5456
55export type ProjectSettingsCategory = "general" | "integrations" | "source-control";
57/** `project` is the Projects page shortcut: the new-thread defaults people change most. */
58export type ProjectSettingsCategory = "general" | "integrations" | "source-control" | "project";
5659
5760export function ProjectSettingsPanel({
5861 projectKey,
......@@ -406,6 +409,12 @@ function ProjectDetail({
406409 return (
407410 <>
408411 <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>
409418 <SettingsSection id="project-overview" title="Project" hideTitle>
410419 <SettingsRow
411420 title="Name"
......@@ -480,6 +489,7 @@ function ProjectDetail({
480489 }
481490 />
482491 </SettingsSection>
492 <ProjectDefaultsSettings category="project" />
483493 <ProjectActionsSettings />
484494 {hasMultipleCheckouts ? checkoutChoices : null}
485495 <SettingsSection title="Danger">
apps/web/src/components/settings/ProjectsSettings.tsx+4-1
......@@ -3,6 +3,7 @@ import { EnvironmentId } from "@t3tools/contracts";
33import { ProjectSettingsPanel } from "./ProjectSettingsPanel";
44import { useSettingsScope } from "./SettingsScopeContext";
55import { SettingsScopeNotice } from "./SettingsScopeNotice";
6import { SettingsPageContainer } from "./settingsLayout";
67
78/** Project identity and checkout management for the selected project. */
89export function ProjectsSettings() {
......@@ -22,7 +23,9 @@ export function ProjectsSettings() {
2223 checkoutKey={value.checkout ?? null}
2324 />
2425 ) : 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>
2629 ) : (
2730 <SettingsScopeNotice target="project">
2831 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) => {
5858 };
5959});
6060
61vi.mock("./SettingsScopeSentence", () => ({ SettingsScopeSentence: () => null }));
6162vi.mock("react/compiler-runtime", async () => {
6263 const { reactHookHarness } = await import("../../test/reactHookHarness");
6364 return { c: reactHookHarness.useMemoCache };
apps/web/src/components/settings/SettingsBreadcrumb.tsx+3-195
......@@ -1,37 +1,9 @@
1import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
2import { LayersIcon } from "lucide-react";
3import type { ReactNode } from "react";
4
5import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
6import type { EnvironmentPresentation } from "../../state/environments";
7import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
8import { ProjectFavicon } from "../ProjectFavicon";
9import { InlineButton } from "../ui/button";
10import {
11 Menu,
12 MenuPopup,
13 MenuRadioGroup,
14 MenuRadioItem,
15 MenuRadioItemIndicator,
16 MenuSeparator,
17 MenuTrigger,
18} from "../ui/menu";
191import {
202 WorkspaceBreadcrumb,
213 WorkspaceBreadcrumbItem,
224 WorkspaceBreadcrumbSeparator,
235} from "../WorkspaceBreadcrumb";
246import { SETTINGS_SECTION_LABELS } from "./settingsSearch";
25import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
26import {
27 ALL_ENVIRONMENTS_VALUE,
28 ALL_PROJECTS_VALUE,
29 environmentAxisValue,
30 projectAxisValue,
31 selectEnvironmentAxis,
32 selectProjectAxis,
33 settingsScopeEnvironmentLabel,
34} from "./settingsScopeAxis";
357
368const SETTINGS_BREADCRUMB_LABELS: Readonly<Record<string, string>> = {
379 ...SETTINGS_SECTION_LABELS,
......@@ -44,27 +16,11 @@ function settingsBreadcrumbLabel(pathname: string): string | null {
4416 return SETTINGS_BREADCRUMB_LABELS[normalizedPathname] ?? null;
4517}
4618
47export interface SettingsScopeBreadcrumbProps {
48 readonly value: SettingsScopeSearch;
49 readonly groups: readonly SidebarProjectSnapshot[];
50 readonly environments: readonly EnvironmentPresentation[];
51 readonly onChange: (next: SettingsScopeSearch) => void;
52}
53
5419/**
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`.
6022 */
61export function SettingsBreadcrumb({
62 pathname,
63 scope,
64}: {
65 pathname: string;
66 scope?: SettingsScopeBreadcrumbProps | undefined;
67}) {
23export function SettingsBreadcrumb({ pathname }: { pathname: string }) {
6824 const sectionLabel = settingsBreadcrumbLabel(pathname);
6925
7026 return (
......@@ -78,154 +34,6 @@ export function SettingsBreadcrumb({
7834 <WorkspaceBreadcrumbItem current className="truncate">
7935 {sectionLabel ?? "Settings"}
8036 </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}
9337 </WorkspaceBreadcrumb>
9438 );
9539}
96
97function 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
125function 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
197function 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 @@
1import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
2import { useLocation } from "@tanstack/react-router";
3import { ChevronDownIcon, LayersIcon } from "lucide-react";
4import type { ReactNode } from "react";
5
6import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
7import { useEnvironments, type EnvironmentPresentation } from "../../state/environments";
8import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
9import { ProjectFavicon } from "../ProjectFavicon";
10import { InlineButton } from "../ui/button";
11import {
12 Menu,
13 MenuPopup,
14 MenuRadioGroup,
15 MenuRadioItem,
16 MenuRadioItemIndicator,
17 MenuSeparator,
18 MenuTrigger,
19} from "../ui/menu";
20import { useOptionalSettingsScope } from "./SettingsScopeContext";
21import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
22import {
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. */
33export const SETTINGS_DEVICE_ONLY_PATHS: ReadonlySet<string> = new Set([
34 "/settings/appearance",
35 "/settings/snap-shot",
36 "/settings/connections",
37]);
38
39interface 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 */
52export 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
81function 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
108function 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
174function 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 () => {
2727vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] }));
2828vi.mock("../../state/server", () => ({ primaryServerKeybindingsAtom: {} }));
2929vi.mock("./SettingsScopeContext", () => ({ useOptionalSettingsScope: () => null }));
30vi.mock("./SettingsScopeSentence", () => ({ SettingsScopeSentence: () => null }));
3031const bridge = vi.hoisted(() => ({
3132 getSnapShotState: vi.fn<() => Promise<DesktopSnapShotState>>(),
3233 setSnapShotShortcutSuppressed: vi.fn(),
apps/web/src/components/settings/settingsLayout.tsx+2
......@@ -23,6 +23,7 @@ import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePag
2323import { Button } from "../ui/button";
2424import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2525import { useOptionalSettingsScope } from "./SettingsScopeContext";
26import { SettingsScopeSentence } from "./SettingsScopeSentence";
2627import {
2728 isProjectScopedSettingKey,
2829 listProjectOverrides,
......@@ -545,6 +546,7 @@ export function SettingsPageContainer({
545546 data-settings-page-scroll
546547 >
547548 <WorkspacePageContainer width={width} className={cn("gap-8", className)}>
549 <SettingsScopeSentence />
548550 {children}
549551 </WorkspacePageContainer>
550552 </div>
apps/web/src/components/ui/button.tsx+3
......@@ -106,6 +106,9 @@ const inlineButtonVariants = cva(
106106 default: "text-foreground",
107107 muted: "text-muted-foreground hover:text-foreground",
108108 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",
109112 },
110113 },
111114 },
apps/web/src/routes/settings.tsx+16-25
......@@ -19,9 +19,10 @@ import {
1919 SettingsScopeProvider,
2020 useSettingsScope,
2121} from "../components/settings/SettingsScopeContext";
22import { useSettingsProjectGroups } from "../components/settings/useSettingsProjectGroups";
2322import { useEnvironments } from "../state/environments";
2423import { SettingsScopeNotice } from "../components/settings/SettingsScopeNotice";
24import { SETTINGS_DEVICE_ONLY_PATHS } from "../components/settings/SettingsScopeSentence";
25import { SettingsPageContainer } from "../components/settings/settingsLayout";
2526import {
2627 retainSettingsScope,
2728 validateSettingsRouteSearch,
......@@ -47,13 +48,6 @@ function RestoreDeviceDefaultsButton({ onRestored }: { onRestored: () => void })
4748 );
4849}
4950
50/** Pages whose every row is saved on this client; the scope selects are hidden there. */
51const DEVICE_ONLY_PATHS = new Set([
52 "/settings/appearance",
53 "/settings/snap-shot",
54 "/settings/connections",
55]);
56
5751function SettingsScopeBoundary({ pathname, children }: { pathname: string; children: ReactNode }) {
5852 const { scope, connectedEnvironments } = useSettingsScope();
5953 const { environments } = useEnvironments();
......@@ -99,16 +93,23 @@ function SettingsScopeBoundary({ pathname, children }: { pathname: string; child
9993 }
10094 // Device-local pages ignore the scope entirely; the project page follows
10195 // 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") {
10397 return children;
10498 }
99 // Keep the scope sentence on screen so the selection can be changed back.
105100 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 );
107106 if (scope.kind === "environment" && connectedEnvironments.length === 0) {
108107 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>
112113 );
113114 }
114115 return children;
......@@ -118,11 +119,8 @@ function SettingsContentLayout() {
118119 const location = useLocation();
119120 const navigate = useNavigate();
120121 const canGoBack = useCanGoBack();
121 const { search, selectScope } = useSettingsScope();
122 const groups = useSettingsProjectGroups();
123 const { environments } = useEnvironments();
122 const { search } = useSettingsScope();
124123 const [restoreSignal, setRestoreSignal] = useState(0);
125 const showScope = !DEVICE_ONLY_PATHS.has(location.pathname);
126124 const navigateBackWithinApp = useCallback(() => {
127125 if (canGoBack) {
128126 window.history.back();
......@@ -157,14 +155,7 @@ function SettingsContentLayout() {
157155 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
158156 <WorkspacePageHeader electron={isElectron}>
159157 <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} />
168159 {location.pathname === "/settings/general" ? (
169160 <div className="ms-auto flex shrink-0 items-center">
170161 <RestoreDeviceDefaultsButton
docs/user/project-settings.md+4-3
......@@ -1,8 +1,9 @@
11# Settings and project overrides
22
3On web and desktop, the Settings breadcrumb ends with the environment and project a change applies to. They start
4at **All environments** and **All projects** and stay selected as you move between categories or
5search for a setting.
3On web and desktop, the "Applying settings for …" sentence at the top of Settings pages picks
4the project and environment a change applies to. Pages that only hold device preferences, such as
5Appearance, don't show it. They start at **All projects** and **All environments**
6and stay selected as you move between categories or search for a setting.
67
78Preferences saved on this device, such as appearance, confirmations and browser profiles, always
89show and ignore the selection. Everything else is stored on a server. Choose one environment to