authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-11 13:56:39-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-11 13:56:39-07:00
log8b2c0465def151f022e01dd04b1b6ba3859bd714
tree6bff0a89416b65e00964fde63714a645b7c45ad6
parent2c0e891740f86205784506a974096cf110b64da7
signature Signed by PGP key B5690EEEBB952194

feat(web): pick settings environment and project as two selects (#10636)


9 files changed, 764 insertions(+), 201 deletions(-)

apps/web/src/components/settings/ProjectSettingsPanel.tsx+26-45
......@@ -68,7 +68,6 @@ import {
6868} from "../../providerInstances";
6969import { getCustomModelOptionsByInstance } from "../../modelSelection";
7070import {
71 buildSidebarProjectSnapshots,
7271 type SidebarProjectGroupMember,
7372 type SidebarProjectSnapshot,
7473} from "../../sidebarProjectGrouping";
......@@ -115,6 +114,7 @@ import {
115114 ProjectFaviconPickerDialog,
116115} from "./ProjectFaviconPickerDialog";
117116import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";
117import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
118118
119119const ProjectIconPickerDialog = lazy(() =>
120120 import("./ProjectIconPickerDialog").then((module) => ({
......@@ -128,31 +128,6 @@ export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, st
128128 separate: "Keep separate",
129129};
130130
131/** Logical project groups for the settings page, sorted by display name. */
132export function useSettingsProjectGroups(): SidebarProjectSnapshot[] {
133 const projects = useProjects();
134 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
135 const primaryEnvironmentId = usePrimaryEnvironmentId();
136 const { environments } = useEnvironments();
137 const environmentLabelById = useMemo(
138 () =>
139 new Map(
140 environments.map((environment) => [environment.environmentId, environment.label] as const),
141 ),
142 [environments],
143 );
144 return useMemo(
145 () =>
146 buildSidebarProjectSnapshots({
147 projects,
148 settings: projectGroupingSettings,
149 primaryEnvironmentId,
150 resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
151 }).sort((a, b) => a.displayName.localeCompare(b.displayName)),
152 [environmentLabelById, primaryEnvironmentId, projectGroupingSettings, projects],
153 );
154}
155
156131function memberKey(member: { environmentId: string; id: string }): string {
157132 return `${member.environmentId}:${member.id}`;
158133}
......@@ -160,9 +135,11 @@ function memberKey(member: { environmentId: string; id: string }): string {
160135export function ProjectSettingsPanel({
161136 projectKey,
162137 environmentId = null,
138 checkoutKey = null,
163139}: {
164140 projectKey: string;
165141 environmentId?: EnvironmentId | null;
142 checkoutKey?: string | null;
166143}) {
167144 const groups = useSettingsProjectGroups();
168145 const navigate = useNavigate();
......@@ -171,9 +148,11 @@ export function ProjectSettingsPanel({
171148 const members = useMemo(
172149 () =>
173150 selected?.memberProjects.filter(
174 (member) => environmentId === null || member.environmentId === environmentId,
151 (member) =>
152 (environmentId === null || member.environmentId === environmentId) &&
153 (checkoutKey === null || member.physicalProjectKey === checkoutKey),
175154 ) ?? [],
176 [selected, environmentId],
155 [selected, environmentId, checkoutKey],
177156 );
178157
179158 // Remember the members of the last rendered group so a grouping-rule change
......@@ -181,6 +160,7 @@ export function ProjectSettingsPanel({
181160 const lastSelectionRef = useRef<{
182161 key: string;
183162 environmentId: EnvironmentId | null;
163 checkoutKey: string | null;
184164 memberKeys: string[];
185165 } | null>(null);
186166 useEffect(() => {
......@@ -188,28 +168,38 @@ export function ProjectSettingsPanel({
188168 lastSelectionRef.current = {
189169 key: selected.projectKey,
190170 environmentId,
171 checkoutKey,
191172 memberKeys: members.map((member) => member.physicalProjectKey),
192173 };
193 }, [selected, members, environmentId]);
174 }, [selected, members, environmentId, checkoutKey]);
194175
195176 // A grouping-rule change replaces the group key mid-visit; follow the
196177 // project to its new key instead of parking on the not-found state.
197178 useEffect(() => {
198179 if (members.length > 0) return;
199180 const last = lastSelectionRef.current;
200 if (last?.key !== projectKey || last.environmentId !== environmentId) return;
181 if (
182 last?.key !== projectKey ||
183 last.environmentId !== environmentId ||
184 last.checkoutKey !== checkoutKey
185 )
186 return;
201187 const successor = groups.find((group) =>
202188 group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)),
203189 );
204190 if (successor) {
205191 void navigate({
206192 to: "/settings/projects",
207 search: { project: successor.projectKey, machine: environmentId ?? undefined },
193 search: {
194 project: successor.projectKey,
195 machine: environmentId ?? undefined,
196 checkout: checkoutKey ?? undefined,
197 },
208198 replace: true,
209199 hashScrollIntoView: false,
210200 });
211201 }
212 }, [groups, navigate, projectKey, members.length, environmentId]);
202 }, [groups, navigate, projectKey, members.length, environmentId, checkoutKey]);
213203
214204 if (!selected) {
215205 return (
......@@ -223,7 +213,7 @@ export function ProjectSettingsPanel({
223213 if (members.length === 0)
224214 return (
225215 <p className="p-8 text-sm text-muted-foreground">
226 This project has no checkout on this machine.
216 This checkout is no longer available in the selected project and environment.
227217 </p>
228218 );
229219 const scopedGroup = {
......@@ -234,7 +224,7 @@ export function ProjectSettingsPanel({
234224 };
235225 return (
236226 <ProjectDetail
237 key={`${selected.projectKey}:${environmentId ?? "all"}`}
227 key={`${selected.projectKey}:${environmentId ?? "all"}:${checkoutKey ?? "all"}`}
238228 group={scopedGroup}
239229 hasOtherMembers={members.length < selected.memberProjects.length}
240230 />
......@@ -879,23 +869,14 @@ function ProjectDetail({
879869 draftStore.clearProjectDraftThreadId(projectRef);
880870 }
881871
882 if (isWholeGroup) {
883 if (hasOtherMembers) {
884 void navigate({
885 to: "/settings/projects",
886 search: { project: group.projectKey, machine: undefined },
887 replace: true,
888 });
889 } else {
890 void navigate({ to: "/", replace: true });
891 }
872 if (isWholeGroup && !hasOtherMembers) {
873 void navigate({ to: "/", replace: true });
892874 }
893875 },
894876 [
895877 deleteProject,
896878 group.displayName,
897879 group.memberProjects.length,
898 group.projectKey,
899880 hasOtherMembers,
900881 navigate,
901882 reportFailure,
apps/web/src/components/settings/ProjectsSettings.tsx+32-147
......@@ -1,168 +1,53 @@
1import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
2import { ChevronDownIcon, FolderIcon } from "lucide-react";
3import { type ReactNode, useState } from "react";
4import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
5import { ProjectFavicon } from "../ProjectFavicon";
61import { WorkspacePageContainer } from "../WorkspacePageContainer";
72import { useEnvironments } from "../../state/environments";
8import { Toggle, ToggleGroup } from "../ui/toggle-group";
9import {
10 Combobox,
11 ComboboxEmpty,
12 ComboboxSearchInput,
13 ComboboxItem,
14 ComboboxList,
15 ComboboxPopup,
16 ComboboxTrigger,
17} from "../ui/combobox";
18import { selectTriggerVariants } from "../ui/select";
19import { cn } from "../../lib/utils";
20import { ProjectSettingsPanel, useSettingsProjectGroups } from "./ProjectSettingsPanel";
3import { ProjectSettingsPanel } from "./ProjectSettingsPanel";
214import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
22
23function ScopePicker({
24 label,
25 value,
26 options,
27 onChange,
28}: {
29 label: "project" | "machine";
30 value: string | null;
31 options: ReadonlyArray<{ value: string; label: string; icon?: ReactNode }>;
32 onChange: (value: string | null) => void;
33}) {
34 const [query, setQuery] = useState("");
35 const selected = options.find((option) => option.value === value);
36 const allIcon =
37 label === "project" ? <FolderIcon aria-hidden className="size-3.5 shrink-0" /> : null;
38 const items = [{ value: "all", label: `All ${label}s`, icon: allIcon }, ...options];
39 return (
40 <Combobox
41 items={items}
42 value={items.find((item) => item.value === (value ?? "all")) ?? null}
43 inputValue={query}
44 onInputValueChange={setQuery}
45 onOpenChange={() => setQuery("")}
46 onValueChange={(next) => {
47 if (next) onChange(next.value === "all" ? null : next.value);
48 }}
49 >
50 <ComboboxTrigger
51 aria-label={`${label === "project" ? "Project" : "Machine"} scope`}
52 className={cn(selectTriggerVariants({ size: "compact" }), "w-auto min-w-0 max-w-52")}
53 >
54 <span className="flex min-w-0 items-center gap-1.5">
55 {value === null ? allIcon : selected?.icon}
56 <span className="truncate">
57 {value === null ? `All ${label}s` : (selected?.label ?? `Unavailable ${label}`)}
58 </span>
59 </span>
60 <ChevronDownIcon aria-hidden className="-me-1 size-3 opacity-50" />
61 </ComboboxTrigger>
62 <ComboboxPopup align="start" className="w-64">
63 <ComboboxSearchInput aria-label={`Search ${label}s`} placeholder={`Search ${label}s...`} />
64 <ComboboxEmpty>No matching {label}s.</ComboboxEmpty>
65 <ComboboxList>
66 {(item: (typeof items)[number]) => (
67 <ComboboxItem
68 key={item.value}
69 value={item}
70 contentClassName="flex min-w-0 items-center gap-2"
71 >
72 {item.icon}
73 <span className="truncate">{item.label}</span>
74 </ComboboxItem>
75 )}
76 </ComboboxList>
77 </ComboboxPopup>
78 </Combobox>
79 );
80}
5import { SettingsScopeSelects } from "./SettingsScopeSelects";
6import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
7import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
818
829export function ProjectsSettings({
83 projectKey,
84 machineId,
10 value,
8511 onScopeChange,
8612}: {
87 projectKey: string | null;
88 machineId: string | null;
89 onScopeChange: (project: string | null, machine: string | null) => void;
13 value: SettingsScopeSearch;
14 onScopeChange: (scope: SettingsScopeSearch) => void;
9015}) {
9116 const groups = useSettingsProjectGroups();
9217 const { environments } = useEnvironments();
93 const machine = environments.find((environment) => environment.environmentId === machineId);
94 const machineOptions = environments.map((environment) => ({
95 value: environment.environmentId,
96 label: environment.label,
97 icon: (
98 <EnvironmentMachineIcon
99 aria-hidden
100 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
101 className="size-3.5 shrink-0"
102 />
103 ),
104 }));
18 const scope = resolveSettingsScope(value, groups, environments);
19 // The panel follows remembered members when grouping replaces a project key.
20 const projectScope =
21 scope.kind === "project" ||
22 scope.kind === "checkout" ||
23 (scope.kind === "unavailable" &&
24 (scope.reason === "project-missing" || scope.reason === "checkout-missing"));
10525 return (
10626 <div className="flex min-h-0 flex-1 flex-col">
10727 <div className="scrollbar-gutter-both shrink-0 overflow-y-auto">
108 <WorkspacePageContainer className="pb-0">
109 <div
110 className="flex flex-wrap items-center gap-2"
111 role="group"
112 aria-label="Settings scope"
113 >
114 {environments.length > 3 ? (
115 <ScopePicker
116 label="machine"
117 value={machineId}
118 options={machineOptions}
119 onChange={(value) => onScopeChange(projectKey, value)}
120 />
121 ) : (
122 <ToggleGroup
123 aria-label="Machine scope"
124 variant="segmented"
125 className="max-w-full flex-wrap"
126 value={[machineId ?? "all"]}
127 onValueChange={(next) => {
128 const value = next[0];
129 if (value) onScopeChange(projectKey, value === "all" ? null : value);
130 }}
131 >
132 <Toggle value="all">All machines</Toggle>
133 {machineOptions.map((option) => (
134 <Toggle key={option.value} value={option.value} title={option.label}>
135 {option.icon}
136 <span className="max-w-36 truncate">{option.label}</span>
137 </Toggle>
138 ))}
139 </ToggleGroup>
140 )}
141 <div className="ms-auto">
142 <ScopePicker
143 label="project"
144 value={projectKey}
145 options={groups.map((group) => ({
146 value: group.projectKey,
147 label: group.displayName,
148 icon: <ProjectFavicon project={group} className="size-3.5" />,
149 }))}
150 onChange={(value) => onScopeChange(value, machineId)}
151 />
152 </div>
153 </div>
28 <WorkspacePageContainer className="items-end pb-0">
29 <SettingsScopeSelects
30 value={value}
31 groups={groups}
32 environments={environments}
33 onChange={onScopeChange}
34 />
15435 </WorkspacePageContainer>
15536 </div>
156 {machineId !== null && !machine ? (
157 <p className="p-8 text-sm text-muted-foreground">This machine is no longer available.</p>
158 ) : projectKey === null ? (
159 <ProjectDefaultsSettings environmentId={machine?.environmentId ?? null} />
160 ) : (
37 {value.project && projectScope ? (
16138 <ProjectSettingsPanel
162 projectKey={projectKey}
163 environmentId={machine?.environmentId ?? null}
39 projectKey={value.project}
40 environmentId={value.machine ? EnvironmentId.make(value.machine) : null}
41 checkoutKey={value.checkout ?? null}
42 />
43 ) : scope.kind === "unavailable" ? (
44 <p className="p-8 text-sm text-muted-foreground">{scope.message}</p>
45 ) : (
46 <ProjectDefaultsSettings
47 environmentId={scope.kind === "environment" ? scope.environmentId : null}
16448 />
16549 )}
16650 </div>
16751 );
16852}
53import { EnvironmentId } from "@t3tools/contracts";
apps/web/src/components/settings/SettingsScopeSelects.logic.test.ts created+88
......@@ -0,0 +1,88 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 environmentAxisValue,
6 projectAxisValue,
7 selectEnvironmentAxis,
8 selectProjectAxis,
9 settingsScopeEnvironmentLabel,
10} from "./SettingsScopeSelects.logic";
11
12const first = {
13 environmentId: EnvironmentId.make("first"),
14 label: "Development",
15 displayUrl: "https://first.example.com",
16};
17const second = {
18 environmentId: EnvironmentId.make("second"),
19 label: "Development",
20 displayUrl: "https://second.example.com",
21};
22
23describe("settings scope environment labels", () => {
24 it("distinguishes same-name environments by address", () => {
25 const environments = [first, second];
26 expect(
27 environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)),
28 ).toEqual([
29 "Development · https://first.example.com",
30 "Development · https://second.example.com",
31 ]);
32 });
33
34 it("falls back to environment IDs when duplicate names have no display URL", () => {
35 const environments = [first, second].map((environment) => ({
36 ...environment,
37 displayUrl: null,
38 }));
39 expect(
40 environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)),
41 ).toEqual(["Development · first", "Development · second"]);
42 });
43
44 it("keeps unique names compact and removes disambiguation after a rename", () => {
45 expect(settingsScopeEnvironmentLabel(first, [first])).toBe("Development");
46 expect(settingsScopeEnvironmentLabel(first, [first, { ...second, label: "Production" }])).toBe(
47 "Development",
48 );
49 });
50});
51
52describe("settings scope axes", () => {
53 it("maps each axis to its search key and back", () => {
54 expect(projectAxisValue({})).toBe("all");
55 expect(projectAxisValue({ project: "app" })).toBe("app");
56 expect(selectProjectAxis({ machine: "second" }, "app")).toEqual({
57 project: "app",
58 machine: "second",
59 });
60 expect(selectProjectAxis({ machine: "second", project: "app" }, "all")).toEqual({
61 machine: "second",
62 });
63 expect(selectEnvironmentAxis({ project: "app" }, "first")).toEqual({
64 project: "app",
65 machine: "first",
66 });
67 expect(selectEnvironmentAxis({ project: "app", machine: "first" }, "all")).toEqual({
68 project: "app",
69 });
70 });
71
72 it("drops a checkout narrowing from older links when either axis changes", () => {
73 const checkout = { project: "app", checkout: "app@first", machine: "first" };
74 expect(selectEnvironmentAxis(checkout, "second")).toEqual({
75 project: "app",
76 machine: "second",
77 });
78 expect(selectProjectAxis(checkout, "app")).toEqual({ project: "app", machine: "first" });
79 });
80});
81
82describe("environmentAxisValue", () => {
83 it("shows the checkout's environment for a legacy checkout link", () => {
84 expect(environmentAxisValue({ project: "p", checkout: "c" }, "laptop")).toBe("laptop");
85 expect(environmentAxisValue({ project: "p" }, null)).toBe("all");
86 expect(environmentAxisValue({ machine: "desk" }, "laptop")).toBe("desk");
87 });
88});
apps/web/src/components/settings/SettingsScopeSelects.logic.ts created+55
......@@ -0,0 +1,55 @@
1import type { EnvironmentPresentation } from "../../state/environments";
2import type { SettingsScopeSearch } from "./settingsScope";
3
4type ScopeEnvironment = Pick<EnvironmentPresentation, "environmentId" | "label" | "displayUrl">;
5
6export function settingsScopeEnvironmentLabel(
7 environment: ScopeEnvironment,
8 environments: readonly ScopeEnvironment[],
9) {
10 const duplicate = environments.some(
11 (other) =>
12 other.environmentId !== environment.environmentId && other.label === environment.label,
13 );
14 return duplicate
15 ? `${environment.label} · ${environment.displayUrl ?? environment.environmentId}`
16 : environment.label;
17}
18
19export const ALL_ENVIRONMENTS_VALUE = "all";
20export const ALL_PROJECTS_VALUE = "all";
21
22/**
23 * The environment axis: `all` or an environment id. A legacy checkout link
24 * without `machine` still names one environment, which the resolver supplies.
25 */
26export function environmentAxisValue(
27 search: SettingsScopeSearch,
28 resolvedEnvironmentId?: string | null,
29): string {
30 return search.machine ?? resolvedEnvironmentId ?? ALL_ENVIRONMENTS_VALUE;
31}
32
33/** The project axis: `all` or a project key. */
34export function projectAxisValue(search: SettingsScopeSearch): string {
35 return search.project ?? ALL_PROJECTS_VALUE;
36}
37
38/** Choosing an environment keeps the project; a pre-existing checkout narrowing is dropped. */
39export function selectEnvironmentAxis(
40 search: SettingsScopeSearch,
41 value: string,
42): SettingsScopeSearch {
43 const next: SettingsScopeSearch = {};
44 if (search.project) next.project = search.project;
45 if (value !== ALL_ENVIRONMENTS_VALUE) next.machine = value;
46 return next;
47}
48
49/** Choosing a project keeps the environment axis. */
50export function selectProjectAxis(search: SettingsScopeSearch, value: string): SettingsScopeSearch {
51 const next: SettingsScopeSearch = {};
52 if (value !== ALL_PROJECTS_VALUE) next.project = value;
53 if (search.machine) next.machine = search.machine;
54 return next;
55}
apps/web/src/components/settings/SettingsScopeSelects.tsx created+154
......@@ -0,0 +1,154 @@
1import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
2import { LayersIcon } from "lucide-react";
3
4import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
5import type { EnvironmentPresentation } from "../../state/environments";
6import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
7import { ProjectFavicon } from "../ProjectFavicon";
8import {
9 Select,
10 SelectGroup,
11 SelectGroupLabel,
12 SelectItem,
13 SelectPopup,
14 SelectTrigger,
15 SelectValue,
16} from "../ui/select";
17import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
18import {
19 ALL_ENVIRONMENTS_VALUE,
20 ALL_PROJECTS_VALUE,
21 environmentAxisValue,
22 projectAxisValue,
23 selectEnvironmentAxis,
24 selectProjectAxis,
25 settingsScopeEnvironmentLabel,
26} from "./SettingsScopeSelects.logic";
27
28const TRIGGER_CLASSNAME = "w-auto min-w-0 max-w-56 justify-start";
29
30/**
31 * Two independent targets: which environments a change applies to, and
32 * which project it overrides. A project is the same project on every
33 * environment; the environment select alone decides where the override is
34 * written. Each axis defaults to "all".
35 */
36export function SettingsScopeSelects({
37 value,
38 groups,
39 environments,
40 onChange,
41}: {
42 value: SettingsScopeSearch;
43 groups: readonly SidebarProjectSnapshot[];
44 environments: readonly EnvironmentPresentation[];
45 onChange: (next: SettingsScopeSearch) => void;
46}) {
47 const resolved = resolveSettingsScope(value, groups, environments);
48 const environmentValue = environmentAxisValue(
49 value,
50 resolved.kind === "checkout" ? resolved.environmentId : null,
51 );
52 const projectValue = projectAxisValue(value);
53 const selectedEnvironment = environments.find(
54 (environment) => environment.environmentId === environmentValue,
55 );
56 const selectedGroup = groups.find((group) => group.projectKey === value.project);
57
58 return (
59 <div className="flex min-w-0 items-center gap-1.5" role="group" aria-label="Settings scope">
60 <Select
61 value={environmentValue}
62 onValueChange={(next) => {
63 if (typeof next === "string") onChange(selectEnvironmentAxis(value, next));
64 }}
65 >
66 <SelectTrigger size="compact" aria-label="Environment scope" className={TRIGGER_CLASSNAME}>
67 <SelectValue>
68 <span className="flex min-w-0 items-center gap-1.5">
69 {selectedEnvironment ? (
70 <EnvironmentMachineIcon
71 aria-hidden
72 kind={resolveEnvironmentMachineKind(selectedEnvironment.serverConfig)}
73 className="size-3.5"
74 />
75 ) : (
76 <LayersIcon aria-hidden className="size-3.5" />
77 )}
78 <span className="truncate">
79 {selectedEnvironment
80 ? settingsScopeEnvironmentLabel(selectedEnvironment, environments)
81 : environmentValue !== ALL_ENVIRONMENTS_VALUE
82 ? "Unavailable environment"
83 : "All environments"}
84 </span>
85 </span>
86 </SelectValue>
87 </SelectTrigger>
88 <SelectPopup align="end" alignItemWithTrigger={false} className="min-w-56">
89 <SelectItem value={ALL_ENVIRONMENTS_VALUE}>
90 <span className="flex items-center gap-2">
91 <LayersIcon aria-hidden className="size-3.5" />
92 All environments
93 </span>
94 </SelectItem>
95 <SelectGroup>
96 <SelectGroupLabel>Environments</SelectGroupLabel>
97 {environments.map((environment) => (
98 <SelectItem key={environment.environmentId} value={environment.environmentId}>
99 <span className="flex w-full min-w-0 items-center gap-2">
100 <EnvironmentMachineIcon
101 aria-hidden
102 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
103 className="size-3.5"
104 />
105 <span className="min-w-0 flex-1 truncate">
106 {settingsScopeEnvironmentLabel(environment, environments)}
107 </span>
108 {environment.connection.phase === "connected" ? null : (
109 <span className="text-xs text-muted-foreground">Offline</span>
110 )}
111 </span>
112 </SelectItem>
113 ))}
114 </SelectGroup>
115 </SelectPopup>
116 </Select>
117
118 <Select
119 value={projectValue}
120 onValueChange={(next) => {
121 if (typeof next === "string") onChange(selectProjectAxis(value, next));
122 }}
123 >
124 <SelectTrigger size="compact" aria-label="Project scope" className={TRIGGER_CLASSNAME}>
125 <SelectValue>
126 <span className="flex min-w-0 items-center gap-1.5">
127 {selectedGroup ? (
128 <ProjectFavicon project={selectedGroup} className="size-3.5" />
129 ) : null}
130 <span className="truncate">
131 {selectedGroup?.displayName ??
132 (value.project ? "Unavailable project" : "All projects")}
133 </span>
134 </span>
135 </SelectValue>
136 </SelectTrigger>
137 <SelectPopup align="end" alignItemWithTrigger={false} className="min-w-56">
138 <SelectItem value={ALL_PROJECTS_VALUE}>All projects</SelectItem>
139 {groups.map((group) => (
140 <SelectItem key={group.projectKey} value={group.projectKey}>
141 <span className="flex w-full min-w-0 items-center gap-2">
142 <ProjectFavicon project={group} className="size-3.5" />
143 <span className="min-w-0 flex-1 truncate">{group.displayName}</span>
144 </span>
145 </SelectItem>
146 ))}
147 </SelectPopup>
148 </Select>
149 {resolved.kind === "unavailable" ? (
150 <span className="truncate text-xs text-muted-foreground">{resolved.message}</span>
151 ) : null}
152 </div>
153 );
154}
apps/web/src/components/settings/settingsScope.test.ts created+216
......@@ -0,0 +1,216 @@
1import { EnvironmentId, ProjectId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import type {
5 SidebarProjectGroupMember,
6 SidebarProjectSnapshot,
7} from "../../sidebarProjectGrouping";
8import { resolveSettingsScope, validateSettingsScopeSearch } from "./settingsScope";
9
10const laptopId = EnvironmentId.make("laptop");
11const serverId = EnvironmentId.make("server");
12const environments = [
13 { environmentId: laptopId, label: "Laptop" },
14 { environmentId: serverId, label: "Server" },
15];
16
17function member(id: string, environmentId: EnvironmentId): SidebarProjectGroupMember {
18 return {
19 id: ProjectId.make(id),
20 environmentId,
21 title: "T3 Code",
22 workspaceRoot: `/repos/${id}`,
23 physicalProjectKey: `${environmentId}:/repos/${id}`,
24 environmentLabel:
25 environments.find((environment) => environment.environmentId === environmentId)?.label ??
26 null,
27 defaultModelSelection: null,
28 scripts: [],
29 createdAt: "2026-09-07T00:00:00.000Z",
30 updatedAt: "2026-09-07T00:00:00.000Z",
31 };
32}
33
34const first = member("first", laptopId);
35const second = member("second", laptopId);
36const third = member("third", serverId);
37const other = member("other", serverId);
38
39function group(
40 projectKey: string,
41 members: readonly SidebarProjectGroupMember[],
42): SidebarProjectSnapshot {
43 return {
44 ...members[0]!,
45 projectKey,
46 displayName: projectKey,
47 memberProjects: members,
48 memberProjectRefs: members.map((project) => ({
49 environmentId: project.environmentId,
50 projectId: project.id,
51 })),
52 groupedProjectCount: members.length,
53 environmentPresence: "mixed",
54 allRemoteMembersAreDesktopLocal: false,
55 allRemoteMembersAreWsl: false,
56 remoteEnvironmentLabels: [],
57 };
58}
59
60const groups = [group("t3code", [first, second, third]), group("other", [other])];
61
62describe("settings scope search", () => {
63 it.each(["device", "all"] as const)(
64 "clears narrower targets for an explicit %s selection",
65 (scope) => {
66 expect(
67 validateSettingsScopeSearch({
68 scope,
69 project: "t3code",
70 machine: laptopId,
71 checkout: first.physicalProjectKey,
72 }),
73 ).toEqual({ scope });
74 },
75 );
76
77 it("retains legacy project and machine links without inventing an explicit broad scope", () => {
78 expect(
79 validateSettingsScopeSearch({ project: "t3code", machine: laptopId, unused: true }),
80 ).toEqual({
81 project: "t3code",
82 machine: laptopId,
83 });
84 });
85
86 it("retains an orphan checkout so it cannot turn into all environments", () => {
87 const search = validateSettingsScopeSearch({ checkout: first.physicalProjectKey });
88 expect(search).toEqual({ checkout: first.physicalProjectKey });
89 expect(resolveSettingsScope(search, groups, environments)).toMatchObject({
90 kind: "unavailable",
91 reason: "project-required",
92 members: [],
93 environmentIds: [],
94 });
95 });
96});
97
98describe("settings scope resolution", () => {
99 it("distinguishes this device from the default aggregate target", () => {
100 expect(resolveSettingsScope({}, groups, environments)).toMatchObject({
101 kind: "all",
102 environmentIds: [laptopId, serverId],
103 });
104 expect(resolveSettingsScope({ scope: "device" }, groups, environments)).toMatchObject({
105 kind: "device",
106 members: [],
107 environmentIds: [],
108 });
109 });
110
111 it("resolves one environment without targeting its project overrides", () => {
112 expect(resolveSettingsScope({ machine: serverId }, groups, environments)).toMatchObject({
113 kind: "environment",
114 environmentId: serverId,
115 environmentIds: [serverId],
116 members: [],
117 });
118 });
119
120 it("keeps all physical members in a project aggregate, including several on one environment", () => {
121 expect(resolveSettingsScope({ project: "t3code" }, groups, environments)).toMatchObject({
122 kind: "project",
123 environmentId: null,
124 members: [first, second, third],
125 environmentIds: [laptopId, serverId],
126 });
127 });
128
129 it("preserves legacy project plus machine aggregates with multiple checkouts", () => {
130 expect(
131 resolveSettingsScope({ project: "t3code", machine: laptopId }, groups, environments),
132 ).toMatchObject({
133 kind: "project",
134 environmentId: laptopId,
135 label: "t3code / Laptop",
136 members: [first, second],
137 environmentIds: [laptopId],
138 });
139 });
140
141 it("narrows a checkout target to exactly one member, deriving its environment when omitted", () => {
142 expect(
143 resolveSettingsScope(
144 { project: "t3code", checkout: second.physicalProjectKey },
145 groups,
146 environments,
147 ),
148 ).toMatchObject({
149 kind: "checkout",
150 checkout: second,
151 environmentId: laptopId,
152 label: "t3code / Laptop · /repos/second",
153 members: [second],
154 environmentIds: [laptopId],
155 });
156 });
157
158 it.each([
159 { project: "missing" },
160 { machine: "removed" },
161 { project: "t3code", machine: "removed" },
162 { project: "t3code", checkout: "deleted" },
163 { project: "other", machine: laptopId },
164 { project: "other", checkout: first.physicalProjectKey },
165 { project: "t3code", machine: serverId, checkout: first.physicalProjectKey },
166 ])("never widens an invalid or stale target: %j", (search) => {
167 expect(resolveSettingsScope(search, groups, environments)).toMatchObject({
168 kind: "unavailable",
169 members: [],
170 environmentIds: [],
171 });
172 });
173
174 it("leaves a removed checkout unavailable while sibling checkouts remain", () => {
175 const search = {
176 project: "t3code",
177 machine: laptopId,
178 checkout: first.physicalProjectKey,
179 };
180 expect(resolveSettingsScope(search, groups, environments)).toMatchObject({
181 kind: "checkout",
182 members: [first],
183 });
184 expect(
185 resolveSettingsScope(search, [group("t3code", [second, third])], environments),
186 ).toMatchObject({ kind: "unavailable", members: [], environmentIds: [] });
187 });
188
189 it("does not select another environment after removing a project's last local checkout", () => {
190 const search = { project: "t3code", machine: laptopId };
191 expect(resolveSettingsScope(search, groups, environments)).toMatchObject({
192 kind: "project",
193 members: [first, second],
194 });
195 expect(resolveSettingsScope(search, [group("t3code", [third])], environments)).toMatchObject({
196 kind: "unavailable",
197 members: [],
198 environmentIds: [],
199 });
200 });
201
202 it("rejects a cached checkout whose environment was removed", () => {
203 expect(
204 resolveSettingsScope(
205 { project: "t3code", checkout: third.physicalProjectKey },
206 groups,
207 environments.slice(0, 1),
208 ),
209 ).toMatchObject({
210 kind: "unavailable",
211 reason: "environment-missing",
212 members: [],
213 environmentIds: [],
214 });
215 });
216});
apps/web/src/components/settings/settingsScope.ts created+163
......@@ -0,0 +1,163 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2
3import type {
4 SidebarProjectGroupMember,
5 SidebarProjectSnapshot,
6} from "../../sidebarProjectGrouping";
7import type { EnvironmentPresentation } from "../../state/environments";
8
9export interface SettingsScopeSearch {
10 scope?: "device" | "all" | undefined;
11 project?: string | undefined;
12 machine?: string | undefined;
13 checkout?: string | undefined;
14}
15
16type ScopeTargets = {
17 label: string;
18 members: readonly SidebarProjectGroupMember[];
19 environmentIds: readonly EnvironmentId[];
20};
21
22export type ResolvedSettingsScope = ScopeTargets &
23 (
24 | { kind: "device" | "all" }
25 | { kind: "environment"; environmentId: EnvironmentId }
26 | {
27 kind: "project";
28 group: SidebarProjectSnapshot;
29 environmentId: EnvironmentId | null;
30 }
31 | {
32 kind: "checkout";
33 group: SidebarProjectSnapshot;
34 checkout: SidebarProjectGroupMember;
35 environmentId: EnvironmentId;
36 }
37 | {
38 kind: "unavailable";
39 reason: "project-required" | "project-missing" | "environment-missing" | "checkout-missing";
40 message: string;
41 }
42 );
43
44/** Explicit broad targets replace narrower selections; stale IDs remain visible to the resolver. */
45export function validateSettingsScopeSearch(raw: Record<string, unknown>): SettingsScopeSearch {
46 if (raw.scope === "device" || raw.scope === "all") return { scope: raw.scope };
47 const stringValue = (value: unknown) =>
48 typeof value === "string" && value.trim().length > 0 ? value : undefined;
49 const project = stringValue(raw.project);
50 const machine = stringValue(raw.machine);
51 const checkout = stringValue(raw.checkout);
52 return {
53 ...(project === undefined ? {} : { project }),
54 ...(machine === undefined ? {} : { machine }),
55 ...(checkout === undefined ? {} : { checkout }),
56 };
57}
58
59/** Resolves only existing targets. An unavailable selection never broadens a subsequent write. */
60export function resolveSettingsScope(
61 search: SettingsScopeSearch,
62 groups: readonly SidebarProjectSnapshot[],
63 environments: readonly Pick<EnvironmentPresentation, "environmentId" | "label">[],
64): ResolvedSettingsScope {
65 const unavailable = (
66 reason: Extract<ResolvedSettingsScope, { kind: "unavailable" }>["reason"],
67 message: string,
68 ): ResolvedSettingsScope => ({
69 kind: "unavailable",
70 reason,
71 label: "Unavailable selection",
72 message,
73 members: [],
74 environmentIds: [],
75 });
76
77 if (search.scope === "device") {
78 return { kind: "device", label: "This device", members: [], environmentIds: [] };
79 }
80 if (search.scope === "all") {
81 return {
82 kind: "all",
83 label: "All environments",
84 members: [],
85 environmentIds: environments.map((environment) => environment.environmentId),
86 };
87 }
88 if (search.checkout && !search.project) {
89 return unavailable("project-required", "Select a project to choose one of its checkouts.");
90 }
91
92 const environment = environments.find((candidate) => candidate.environmentId === search.machine);
93 if (search.machine && !environment) {
94 return unavailable("environment-missing", "This environment is no longer available.");
95 }
96
97 if (search.project) {
98 const group = groups.find((candidate) => candidate.projectKey === search.project);
99 if (!group) return unavailable("project-missing", "This project is no longer available.");
100 const members = group.memberProjects.filter(
101 (member) =>
102 (search.machine === undefined || member.environmentId === search.machine) &&
103 (search.checkout === undefined || member.physicalProjectKey === search.checkout),
104 );
105 if (members.length === 0) {
106 return unavailable(
107 "checkout-missing",
108 search.checkout
109 ? "This checkout is no longer available in the selected project and environment."
110 : "This project has no checkout on this environment.",
111 );
112 }
113 if (search.checkout) {
114 const checkout = members[0]!;
115 const checkoutEnvironment = environments.find(
116 (candidate) => candidate.environmentId === checkout.environmentId,
117 );
118 if (!checkoutEnvironment) {
119 return unavailable(
120 "environment-missing",
121 "This checkout's environment is no longer available.",
122 );
123 }
124 const sharesEnvironment = group.memberProjects.some(
125 (member) =>
126 member.environmentId === checkout.environmentId &&
127 member.physicalProjectKey !== checkout.physicalProjectKey,
128 );
129 return {
130 kind: "checkout",
131 group,
132 checkout,
133 environmentId: checkout.environmentId,
134 label: `${group.displayName} / ${checkoutEnvironment.label}${sharesEnvironment ? ` · ${checkout.workspaceRoot}` : ""}`,
135 members,
136 environmentIds: [checkout.environmentId],
137 };
138 }
139 return {
140 kind: "project",
141 group,
142 environmentId: environment?.environmentId ?? null,
143 label: `${group.displayName} / ${environment?.label ?? "All checkouts"}`,
144 members,
145 environmentIds: [...new Set(members.map((member) => member.environmentId))],
146 };
147 }
148 if (environment) {
149 return {
150 kind: "environment",
151 environmentId: environment.environmentId,
152 label: environment.label,
153 members: [],
154 environmentIds: [environment.environmentId],
155 };
156 }
157 return {
158 kind: "all",
159 label: "All environments",
160 members: [],
161 environmentIds: environments.map((candidate) => candidate.environmentId),
162 };
163}
apps/web/src/components/settings/useSettingsProjectGroups.ts created+24
......@@ -0,0 +1,24 @@
1import { useMemo } from "react";
2
3import { useClientSettings } from "../../hooks/useSettings";
4import { selectProjectGroupingSettings } from "../../logicalProject";
5import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping";
6import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
7import { useProjects } from "../../state/entities";
8
9/** Settings uses the same logical projects as the sidebar, sorted by display name. */
10export function useSettingsProjectGroups() {
11 const projects = useProjects();
12 const settings = useClientSettings(selectProjectGroupingSettings);
13 const primaryEnvironmentId = usePrimaryEnvironmentId();
14 const { environments } = useEnvironments();
15 return useMemo(() => {
16 const labels = new Map(environments.map((entry) => [entry.environmentId, entry.label]));
17 return buildSidebarProjectSnapshots({
18 projects,
19 settings,
20 primaryEnvironmentId,
21 resolveEnvironmentLabel: (id) => labels.get(id) ?? null,
22 }).sort((a, b) => a.displayName.localeCompare(b.displayName));
23 }, [environments, primaryEnvironmentId, projects, settings]);
24}
apps/web/src/routes/settings.projects.tsx+6-9
......@@ -1,24 +1,21 @@
11import { createFileRoute } from "@tanstack/react-router";
22import { ProjectsSettings } from "../components/settings/ProjectsSettings";
3import { validateSettingsScopeSearch } from "../components/settings/settingsScope";
34
45export const Route = createFileRoute("/settings/projects")({
5 validateSearch: (search: Record<string, unknown>) => ({
6 project: typeof search.project === "string" ? search.project : undefined,
7 machine: typeof search.machine === "string" ? search.machine : undefined,
8 }),
6 validateSearch: validateSettingsScopeSearch,
97 component: ProjectsRoute,
108});
119
1210function ProjectsRoute() {
13 const { project, machine } = Route.useSearch();
11 const search = Route.useSearch();
1412 const navigate = Route.useNavigate();
1513 return (
1614 <ProjectsSettings
17 projectKey={project ?? null}
18 machineId={machine ?? null}
19 onScopeChange={(project, machine) => {
15 value={search}
16 onScopeChange={(scope) => {
2017 void navigate({
21 search: { project: project ?? undefined, machine: machine ?? undefined },
18 search: scope,
2219 replace: true,
2320 });
2421 }}