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...@@ -129,97 +129,151 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
129 updateSettings({ defaultModelSelection: value });129 updateSettings({ defaultModelSelection: value });
130 };130 };
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
132 return (252 return (
133 <SettingsSection253 <SettingsSection
134 id={254 id={
135 category === "general"255 category === "general" || category === "project"
136 ? "project-defaults"256 ? "project-defaults"
137 : category === "integrations"257 : category === "integrations"
138 ? "browser-access"258 ? "browser-access"
139 : "source-control-defaults"259 : "source-control-defaults"
140 }260 }
141 title={261 title={
142 category === "general"262 category === "general" || category === "project"
143 ? "New threads"263 ? "New threads"
144 : category === "integrations"264 : category === "integrations"
145 ? "Browser"265 ? "Browser"
146 : "Repositories"266 : "Repositories"
147 }267 }
148 >268 >
149 {category === "general" ? (269 {category === "project" ? (
150 <>270 <>
151 <SettingsRow271 {modelRow}
152 serverScoped272 {workspaceRow}
153 settingKeys={["defaultModelSelection"]}273 </>
154 mixed={mixedModel}274 ) : category === "general" ? (
155 id="default-model"275 <>
156 title="Model"276 {modelRow}
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 />
223 <SettingsRow277 <SettingsRow
224 serverScoped278 serverScoped
225 settingKeys={["defaultRuntimeMode"]}279 settingKeys={["defaultRuntimeMode"]}
...@@ -281,51 +335,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting...@@ -281,51 +335,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
281 </Select>335 </Select>
282 }336 }
283 />337 />
284 <SettingsRow338 {workspaceRow}
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 />
329 <SettingsRow339 <SettingsRow
330 serverScoped340 serverScoped
331 settingKeys={["worktreeSubmodules"]}341 settingKeys={["worktreeSubmodules"]}
apps/web/src/components/settings/ProjectSettingsPanel.tsx+12-2
...@@ -10,7 +10,7 @@ import { AsyncResult } from "effect/unstable/reactivity";...@@ -10,7 +10,7 @@ import { AsyncResult } from "effect/unstable/reactivity";
10import { type EnvironmentId, type ProjectIconOverride } from "@t3tools/contracts";10import { type EnvironmentId, type ProjectIconOverride } from "@t3tools/contracts";
11import { useLocation, useNavigate } from "@tanstack/react-router";11import { useLocation, useNavigate } from "@tanstack/react-router";
12import * as Cause from "effect/Cause";12import * as Cause from "effect/Cause";
13import { Trash2Icon } from "lucide-react";13import { InfoIcon, Trash2Icon } from "lucide-react";
14import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";14import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
1515
16import { useComposerDraftStore } from "../../composerDraftStore";16import { useComposerDraftStore } from "../../composerDraftStore";
...@@ -25,6 +25,7 @@ import { useThreadShells } from "../../state/entities";...@@ -25,6 +25,7 @@ import { useThreadShells } from "../../state/entities";
25import { projectEnvironment } from "../../state/projects";25import { projectEnvironment } from "../../state/projects";
26import { useAtomCommand } from "../../state/use-atom-command";26import { useAtomCommand } from "../../state/use-atom-command";
27import { ProjectFavicon } from "../ProjectFavicon";27import { ProjectFavicon } from "../ProjectFavicon";
28import { Alert, AlertDescription } from "../ui/alert";
28import { Button } from "../ui/button";29import { Button } from "../ui/button";
29import { Input } from "../ui/input";30import { Input } from "../ui/input";
30import { stackedThreadToast, toastManager } from "../ui/toast";31import { stackedThreadToast, toastManager } from "../ui/toast";
...@@ -39,6 +40,7 @@ import {...@@ -39,6 +40,7 @@ import {
39 ProjectFaviconPickerDialog,40 ProjectFaviconPickerDialog,
40} from "./ProjectFaviconPickerDialog";41} from "./ProjectFaviconPickerDialog";
41import { ProjectActionsSettings } from "./ProjectActionsSettings";42import { ProjectActionsSettings } from "./ProjectActionsSettings";
43import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
42import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";44import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";
43import { useSettingsProjectGroups } from "./useSettingsProjectGroups";45import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
4446
...@@ -52,7 +54,8 @@ function memberKey(member: { environmentId: string; id: string }): string {...@@ -52,7 +54,8 @@ function memberKey(member: { environmentId: string; id: string }): string {
52 return `${member.environmentId}:${member.id}`;54 return `${member.environmentId}:${member.id}`;
53}55}
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
57export function ProjectSettingsPanel({60export function ProjectSettingsPanel({
58 projectKey,61 projectKey,
...@@ -406,6 +409,12 @@ function ProjectDetail({...@@ -406,6 +409,12 @@ function ProjectDetail({
406 return (409 return (
407 <>410 <>
408 <SettingsPageContainer className="gap-6">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 <SettingsSection id="project-overview" title="Project" hideTitle>418 <SettingsSection id="project-overview" title="Project" hideTitle>
410 <SettingsRow419 <SettingsRow
411 title="Name"420 title="Name"
...@@ -480,6 +489,7 @@ function ProjectDetail({...@@ -480,6 +489,7 @@ function ProjectDetail({
480 }489 }
481 />490 />
482 </SettingsSection>491 </SettingsSection>
492 <ProjectDefaultsSettings category="project" />
483 <ProjectActionsSettings />493 <ProjectActionsSettings />
484 {hasMultipleCheckouts ? checkoutChoices : null}494 {hasMultipleCheckouts ? checkoutChoices : null}
485 <SettingsSection title="Danger">495 <SettingsSection title="Danger">
apps/web/src/components/settings/ProjectsSettings.tsx+4-1
...@@ -3,6 +3,7 @@ import { EnvironmentId } from "@t3tools/contracts";...@@ -3,6 +3,7 @@ import { EnvironmentId } from "@t3tools/contracts";
3import { ProjectSettingsPanel } from "./ProjectSettingsPanel";3import { ProjectSettingsPanel } from "./ProjectSettingsPanel";
4import { useSettingsScope } from "./SettingsScopeContext";4import { useSettingsScope } from "./SettingsScopeContext";
5import { SettingsScopeNotice } from "./SettingsScopeNotice";5import { SettingsScopeNotice } from "./SettingsScopeNotice";
6import { SettingsPageContainer } from "./settingsLayout";
67
7/** Project identity and checkout management for the selected project. */8/** Project identity and checkout management for the selected project. */
8export function ProjectsSettings() {9export function ProjectsSettings() {
...@@ -22,7 +23,9 @@ export function ProjectsSettings() {...@@ -22,7 +23,9 @@ export function ProjectsSettings() {
22 checkoutKey={value.checkout ?? null}23 checkoutKey={value.checkout ?? null}
23 />24 />
24 ) : scope.kind === "unavailable" ? (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 <SettingsScopeNotice target="project">30 <SettingsScopeNotice target="project">
28 Choose a project to manage its name, icon, checkouts and actions.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,6 +58,7 @@ vi.mock("./settingsLayout", async (importOriginal) => {
58 };58 };
59});59});
6060
61vi.mock("./SettingsScopeSentence", () => ({ SettingsScopeSentence: () => null }));
61vi.mock("react/compiler-runtime", async () => {62vi.mock("react/compiler-runtime", async () => {
62 const { reactHookHarness } = await import("../../test/reactHookHarness");63 const { reactHookHarness } = await import("../../test/reactHookHarness");
63 return { c: reactHookHarness.useMemoCache };64 return { c: reactHookHarness.useMemoCache };
apps/web/src/components/settings/SettingsBreadcrumb.tsx+3-195
...@@ -1,37 +1,9 @@...@@ -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";
19import {1import {
20 WorkspaceBreadcrumb,2 WorkspaceBreadcrumb,
21 WorkspaceBreadcrumbItem,3 WorkspaceBreadcrumbItem,
22 WorkspaceBreadcrumbSeparator,4 WorkspaceBreadcrumbSeparator,
23} from "../WorkspaceBreadcrumb";5} from "../WorkspaceBreadcrumb";
24import { SETTINGS_SECTION_LABELS } from "./settingsSearch";6import { 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
36const SETTINGS_BREADCRUMB_LABELS: Readonly<Record<string, string>> = {8const SETTINGS_BREADCRUMB_LABELS: Readonly<Record<string, string>> = {
37 ...SETTINGS_SECTION_LABELS,9 ...SETTINGS_SECTION_LABELS,
...@@ -44,27 +16,11 @@ function settingsBreadcrumbLabel(pathname: string): string | null {...@@ -44,27 +16,11 @@ function settingsBreadcrumbLabel(pathname: string): string | null {
44 return SETTINGS_BREADCRUMB_LABELS[normalizedPathname] ?? null;16 return SETTINGS_BREADCRUMB_LABELS[normalizedPathname] ?? null;
45}17}
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
54/**19/**
55 * `Settings / Section / Environment / Project`. The last two crumbs are the20 * `Settings / Section`. The scope a change applies to lives at the top of the
56 * targets a change applies to and read like the usage page's filter: muted at21 * page content, see `SettingsScopeSentence`.
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.
60 */22 */
61export function SettingsBreadcrumb({23export function SettingsBreadcrumb({ pathname }: { pathname: string }) {
62 pathname,
63 scope,
64}: {
65 pathname: string;
66 scope?: SettingsScopeBreadcrumbProps | undefined;
67}) {
68 const sectionLabel = settingsBreadcrumbLabel(pathname);24 const sectionLabel = settingsBreadcrumbLabel(pathname);
6925
70 return (26 return (
...@@ -78,154 +34,6 @@ export function SettingsBreadcrumb({...@@ -78,154 +34,6 @@ export function SettingsBreadcrumb({
78 <WorkspaceBreadcrumbItem current className="truncate">34 <WorkspaceBreadcrumbItem current className="truncate">
79 {sectionLabel ?? "Settings"}35 {sectionLabel ?? "Settings"}
80 </WorkspaceBreadcrumbItem>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 </WorkspaceBreadcrumb>37 </WorkspaceBreadcrumb>
94 );38 );
95}39}
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 () => {...@@ -27,6 +27,7 @@ vi.mock("react/compiler-runtime", async () => {
27vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] }));27vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] }));
28vi.mock("../../state/server", () => ({ primaryServerKeybindingsAtom: {} }));28vi.mock("../../state/server", () => ({ primaryServerKeybindingsAtom: {} }));
29vi.mock("./SettingsScopeContext", () => ({ useOptionalSettingsScope: () => null }));29vi.mock("./SettingsScopeContext", () => ({ useOptionalSettingsScope: () => null }));
30vi.mock("./SettingsScopeSentence", () => ({ SettingsScopeSentence: () => null }));
30const bridge = vi.hoisted(() => ({31const bridge = vi.hoisted(() => ({
31 getSnapShotState: vi.fn<() => Promise<DesktopSnapShotState>>(),32 getSnapShotState: vi.fn<() => Promise<DesktopSnapShotState>>(),
32 setSnapShotShortcutSuppressed: vi.fn(),33 setSnapShotShortcutSuppressed: vi.fn(),
apps/web/src/components/settings/settingsLayout.tsx+2
...@@ -23,6 +23,7 @@ import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePag...@@ -23,6 +23,7 @@ import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePag
23import { Button } from "../ui/button";23import { Button } from "../ui/button";
24import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";24import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
25import { useOptionalSettingsScope } from "./SettingsScopeContext";25import { useOptionalSettingsScope } from "./SettingsScopeContext";
26import { SettingsScopeSentence } from "./SettingsScopeSentence";
26import {27import {
27 isProjectScopedSettingKey,28 isProjectScopedSettingKey,
28 listProjectOverrides,29 listProjectOverrides,
...@@ -545,6 +546,7 @@ export function SettingsPageContainer({...@@ -545,6 +546,7 @@ export function SettingsPageContainer({
545 data-settings-page-scroll546 data-settings-page-scroll
546 >547 >
547 <WorkspacePageContainer width={width} className={cn("gap-8", className)}>548 <WorkspacePageContainer width={width} className={cn("gap-8", className)}>
549 <SettingsScopeSentence />
548 {children}550 {children}
549 </WorkspacePageContainer>551 </WorkspacePageContainer>
550 </div>552 </div>
apps/web/src/components/ui/button.tsx+3
...@@ -106,6 +106,9 @@ const inlineButtonVariants = cva(...@@ -106,6 +106,9 @@ const inlineButtonVariants = cva(
106 default: "text-foreground",106 default: "text-foreground",
107 muted: "text-muted-foreground hover:text-foreground",107 muted: "text-muted-foreground hover:text-foreground",
108 destructive: "text-destructive/80 hover:text-destructive",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,9 +19,10 @@ import {
19 SettingsScopeProvider,19 SettingsScopeProvider,
20 useSettingsScope,20 useSettingsScope,
21} from "../components/settings/SettingsScopeContext";21} from "../components/settings/SettingsScopeContext";
22import { useSettingsProjectGroups } from "../components/settings/useSettingsProjectGroups";
23import { useEnvironments } from "../state/environments";22import { useEnvironments } from "../state/environments";
24import { SettingsScopeNotice } from "../components/settings/SettingsScopeNotice";23import { SettingsScopeNotice } from "../components/settings/SettingsScopeNotice";
24import { SETTINGS_DEVICE_ONLY_PATHS } from "../components/settings/SettingsScopeSentence";
25import { SettingsPageContainer } from "../components/settings/settingsLayout";
25import {26import {
26 retainSettingsScope,27 retainSettingsScope,
27 validateSettingsRouteSearch,28 validateSettingsRouteSearch,
...@@ -47,13 +48,6 @@ function RestoreDeviceDefaultsButton({ onRestored }: { onRestored: () => void })...@@ -47,13 +48,6 @@ function RestoreDeviceDefaultsButton({ onRestored }: { onRestored: () => void })
47 );48 );
48}49}
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
57function SettingsScopeBoundary({ pathname, children }: { pathname: string; children: ReactNode }) {51function SettingsScopeBoundary({ pathname, children }: { pathname: string; children: ReactNode }) {
58 const { scope, connectedEnvironments } = useSettingsScope();52 const { scope, connectedEnvironments } = useSettingsScope();
59 const { environments } = useEnvironments();53 const { environments } = useEnvironments();
...@@ -99,16 +93,23 @@ function SettingsScopeBoundary({ pathname, children }: { pathname: string; child...@@ -99,16 +93,23 @@ function SettingsScopeBoundary({ pathname, children }: { pathname: string; child
99 }93 }
100 // Device-local pages ignore the scope entirely; the project page follows94 // Device-local pages ignore the scope entirely; the project page follows
101 // remembered members while a grouping change replaces its URL key.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 return children;97 return children;
104 }98 }
99 // Keep the scope sentence on screen so the selection can be changed back.
105 if (scope.kind === "unavailable")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 if (scope.kind === "environment" && connectedEnvironments.length === 0) {106 if (scope.kind === "environment" && connectedEnvironments.length === 0) {
108 return (107 return (
109 <p className="p-8 text-sm text-muted-foreground">108 <SettingsPageContainer>
110 Reconnect {scope.label} to change its settings.109 <p className="text-sm text-muted-foreground">
111 </p>110 Reconnect {scope.label} to change its settings.
111 </p>
112 </SettingsPageContainer>
112 );113 );
113 }114 }
114 return children;115 return children;
...@@ -118,11 +119,8 @@ function SettingsContentLayout() {...@@ -118,11 +119,8 @@ function SettingsContentLayout() {
118 const location = useLocation();119 const location = useLocation();
119 const navigate = useNavigate();120 const navigate = useNavigate();
120 const canGoBack = useCanGoBack();121 const canGoBack = useCanGoBack();
121 const { search, selectScope } = useSettingsScope();122 const { search } = useSettingsScope();
122 const groups = useSettingsProjectGroups();
123 const { environments } = useEnvironments();
124 const [restoreSignal, setRestoreSignal] = useState(0);123 const [restoreSignal, setRestoreSignal] = useState(0);
125 const showScope = !DEVICE_ONLY_PATHS.has(location.pathname);
126 const navigateBackWithinApp = useCallback(() => {124 const navigateBackWithinApp = useCallback(() => {
127 if (canGoBack) {125 if (canGoBack) {
128 window.history.back();126 window.history.back();
...@@ -157,14 +155,7 @@ function SettingsContentLayout() {...@@ -157,14 +155,7 @@ function SettingsContentLayout() {
157 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">155 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
158 <WorkspacePageHeader electron={isElectron}>156 <WorkspacePageHeader electron={isElectron}>
159 <div className="flex w-full items-center gap-3">157 <div className="flex w-full items-center gap-3">
160 <SettingsBreadcrumb158 <SettingsBreadcrumb pathname={location.pathname} />
161 pathname={location.pathname}
162 scope={
163 showScope
164 ? { value: search, groups, environments, onChange: selectScope }
165 : undefined
166 }
167 />
168 {location.pathname === "/settings/general" ? (159 {location.pathname === "/settings/general" ? (
169 <div className="ms-auto flex shrink-0 items-center">160 <div className="ms-auto flex shrink-0 items-center">
170 <RestoreDeviceDefaultsButton161 <RestoreDeviceDefaultsButton
docs/user/project-settings.md+4-3
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1# Settings and project overrides1# Settings and project overrides
22
3On web and desktop, the Settings breadcrumb ends with the environment and project a change applies to. They start3On web and desktop, the "Applying settings for …" sentence at the top of Settings pages picks
4at **All environments** and **All projects** and stay selected as you move between categories or4the project and environment a change applies to. Pages that only hold device preferences, such as
5search for a setting.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
7Preferences saved on this device, such as appearance, confirmations and browser profiles, always8Preferences saved on this device, such as appearance, confirmations and browser profiles, always
8show and ignore the selection. Everything else is stored on a server. Choose one environment to9show and ignore the selection. Everything else is stored on a server. Choose one environment to