authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-01 15:03:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 18:03:47-04:00
logc0995d2eaf8ec787b3318ed1169ae266ed1529f8
tree4d4ec0e589d0c5f34da675bebc7bec5be2fc18fd
parentd0b4acbd13b2b602710e4a7d60c42f4799a409be
signature Signed by PGP key B5690EEEBB952194

fix(web): keep the selected environment when changing projects (#9102)


3 files changed, 81 insertions(+), 7 deletions(-)

apps/web/src/environmentGrouping.test.ts+68
...@@ -323,6 +323,74 @@ describe("environment grouping", () => {...@@ -323,6 +323,74 @@ describe("environment grouping", () => {
323 expect(entries[1]?.group.displayName).toBe("separate");323 expect(entries[1]?.group.displayName).toBe("separate");
324 });324 });
325325
326 it("keeps the current environment when available and falls back otherwise", () => {
327 const currentPrimary = makeProject({ repositoryIdentity });
328 const currentRemote = makeProject({
329 id: ProjectId.make("current-remote"),
330 environmentId: remoteEnvironmentId,
331 repositoryIdentity,
332 });
333 const destinationRepositoryIdentity = {
334 canonicalKey: "github.com/example/destination",
335 locator: {
336 source: "git-remote" as const,
337 remoteName: "origin",
338 remoteUrl: "https://github.com/example/destination.git",
339 },
340 };
341 const destinationPrimary = makeProject({
342 id: ProjectId.make("destination-primary"),
343 title: "destination",
344 workspaceRoot: "/tmp/destination",
345 repositoryIdentity: destinationRepositoryIdentity,
346 });
347 const destinationRemote = makeProject({
348 id: ProjectId.make("destination-remote"),
349 environmentId: remoteEnvironmentId,
350 title: "destination",
351 workspaceRoot: "/remote/destination",
352 repositoryIdentity: destinationRepositoryIdentity,
353 });
354 const fallbackPrimary = makeProject({
355 id: ProjectId.make("fallback-primary"),
356 title: "fallback",
357 workspaceRoot: "/tmp/fallback",
358 });
359 const groups = buildSidebarProjectSnapshots({
360 projects: [
361 currentPrimary,
362 currentRemote,
363 destinationPrimary,
364 destinationRemote,
365 fallbackPrimary,
366 ],
367 settings: defaultGroupingSettings,
368 primaryEnvironmentId,
369 resolveEnvironmentLabel: () => null,
370 });
371
372 const entries = buildSidebarProjectPickerEntries({
373 groups,
374 preferredProjectRef: {
375 environmentId: remoteEnvironmentId,
376 projectId: currentRemote.id,
377 },
378 });
379 const destination = entries.find(
380 (entry) => entry.group.projectKey === destinationRepositoryIdentity.canonicalKey,
381 );
382 const fallback = entries.find((entry) => entry.group.displayName === "fallback");
383
384 expect(destination?.targetProject).toMatchObject({
385 environmentId: remoteEnvironmentId,
386 id: destinationRemote.id,
387 });
388 expect(fallback?.targetProject).toMatchObject({
389 environmentId: primaryEnvironmentId,
390 id: fallbackPrimary.id,
391 });
392 });
393
326 it("keeps manual project order when building grouped sidebar entries", () => {394 it("keeps manual project order when building grouped sidebar entries", () => {
327 const primary = makeProject({ repositoryIdentity });395 const primary = makeProject({ repositoryIdentity });
328 const remote = makeProject({396 const remote = makeProject({
apps/web/src/sidebarProjectGrouping.ts+8-7
...@@ -118,22 +118,23 @@ export function buildSidebarProjectPickerEntries(input: {...@@ -118,22 +118,23 @@ export function buildSidebarProjectPickerEntries(input: {
118 groups: ReadonlyArray<SidebarProjectSnapshot>;118 groups: ReadonlyArray<SidebarProjectSnapshot>;
119 preferredProjectRef: ScopedProjectRef | null;119 preferredProjectRef: ScopedProjectRef | null;
120}) {120}) {
121 const preferredProjectRef = input.preferredProjectRef;
121 const entries = input.groups.flatMap((group): SidebarProjectPickerEntry[] => {122 const entries = input.groups.flatMap((group): SidebarProjectPickerEntry[] => {
122 const isPreferred = input.preferredProjectRef123 const isPreferred = preferredProjectRef
123 ? group.memberProjectRefs.some(124 ? group.memberProjectRefs.some(
124 (projectRef) =>125 (projectRef) =>
125 projectRef.environmentId === input.preferredProjectRef?.environmentId &&126 projectRef.environmentId === preferredProjectRef.environmentId &&
126 projectRef.projectId === input.preferredProjectRef.projectId,127 projectRef.projectId === preferredProjectRef.projectId,
127 )128 )
128 : false;129 : false;
129 const preferredProject = isPreferred130 const preferredProject = preferredProjectRef
130 ? (group.memberProjects.find(131 ? (group.memberProjects.find(
131 (project) =>132 (project) =>
132 project.environmentId === input.preferredProjectRef?.environmentId &&133 project.environmentId === preferredProjectRef.environmentId &&
133 project.id === input.preferredProjectRef?.projectId,134 project.id === preferredProjectRef.projectId,
134 ) ??135 ) ??
135 group.memberProjects.find(136 group.memberProjects.find(
136 (project) => project.environmentId === input.preferredProjectRef?.environmentId,137 (project) => project.environmentId === preferredProjectRef.environmentId,
137 ))138 ))
138 : null;139 : null;
139 const targetProject =140 const targetProject =
docs/user/composer.md+5
...@@ -52,6 +52,11 @@ On mobile, the model picker shows each OpenCode model's upstream provider, such...@@ -52,6 +52,11 @@ On mobile, the model picker shows each OpenCode model's upstream provider, such
52GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list52GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list
53when starting a thread or changing an existing thread's model.53when starting a thread or changing an existing thread's model.
5454
55## Changing projects
56
57On web and desktop, changing the project from a new thread keeps the current environment when that
58project exists there. If it does not, T3 Code selects another environment that has the project.
59
55## Notices above the composer60## Notices above the composer
5661
57On web and desktop, loading and syncing statuses fill the available banner width beside the62On web and desktop, loading and syncing statuses fill the available banner width beside the