authorgravatar for me@pixp.ccAlex <me@pixp.cc> 2026-10-11 11:42:43-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 11:42:43-07:00
log8de61d4a97635382ee08d098b2a588e88532cc0a
tree7428fa78ab07516e252c5972c08961ece093850a
parent8863990fcaecfb59dbd6c338e49aba603ecccdfd
signature Signed by PGP key B5690EEEBB952194

fix(client): merge No project filters across environments (#17050)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

14 files changed, 704 insertions(+), 28 deletions(-)

apps/mobile/src/features/home/HomeRouteScreen.tsx+11-1
......@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
77import { Platform, useWindowDimensions } from "react-native";
88
99import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
10import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping";
1011
1112import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
1213import { useProjects } from "../../state/entities";
......@@ -119,7 +120,16 @@ export function HomeRouteScreen() {
119120 setSelectedProjectKey,
120121 } = useHomeListOptions(availableEnvironmentIds);
121122 const selectedEnvironmentId = listOptions.selectedEnvironmentId;
122 const selectedProjectKey = listOptions.selectedProjectKey;
123 const projectScopeKey = listOptions.selectedProjectKey;
124 const selectedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, {
125 sidebarProjectGroupingMode: listOptions.projectGroupingMode,
126 sidebarProjectGroupingOverrides: {},
127 });
128 useEffect(() => {
129 if (selectedProjectKey !== projectScopeKey) {
130 setSelectedProjectKey(selectedProjectKey);
131 }
132 }, [projectScopeKey, selectedProjectKey, setSelectedProjectKey]);
123133 const projectFilterOptions = useMemo(
124134 () =>
125135 buildHomeProjectScopes({
apps/mobile/src/features/home/homeThreadList.test.ts+74
......@@ -11,6 +11,7 @@ import {
1111 sortHomeProjectScopes,
1212} from "./homeThreadList";
1313import { makeThreadShellFixture } from "../../test-fixtures";
14import { buildThreadListV2Items } from "../threads/threadListV2";
1415
1516function makeProject(
1617 input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">,
......@@ -48,6 +49,79 @@ function makeThread(
4849}
4950
5051describe("home project scopes", () => {
52 it("filters threads across both scratch hosts while retaining the selected environment boundary", () => {
53 const localEnvironmentId = EnvironmentId.make("local");
54 const remoteEnvironmentId = EnvironmentId.make("remote");
55 const projects = [
56 makeProject({
57 environmentId: localEnvironmentId,
58 id: ProjectId.make("scratch-local"),
59 title: "No project",
60 workspaceRoot: "/local/scratch",
61 isScratch: true,
62 }),
63 makeProject({
64 environmentId: remoteEnvironmentId,
65 id: ProjectId.make("scratch-remote"),
66 title: "No project",
67 workspaceRoot: "/remote/scratch",
68 isScratch: true,
69 }),
70 makeProject({
71 environmentId: remoteEnvironmentId,
72 id: ProjectId.make("ordinary"),
73 title: "No project",
74 }),
75 ];
76 const threads = projects.map((project) =>
77 makeThread({
78 environmentId: project.environmentId,
79 projectId: project.id,
80 id: ThreadId.make(`thread-${project.id}`),
81 title: project.id,
82 }),
83 );
84 const scopes = buildHomeProjectScopes({
85 projects,
86 environmentId: null,
87 projectGroupingMode: "separate",
88 });
89 expect(scopes).toHaveLength(2);
90 const scratchScope = scopes.find((scope) => scope.representative.isScratch)!;
91 const filtered = buildThreadListV2Items({
92 threads,
93 environmentId: null,
94 projectRefs: scratchScope.projectRefs,
95 searchQuery: "",
96 now: "2026-06-01T00:00:00.000Z",
97 });
98 expect(filtered.items.map((item) => item.thread.id).sort()).toEqual(
99 threads
100 .slice(0, 2)
101 .map((thread) => thread.id)
102 .sort(),
103 );
104 const remoteScopes = buildHomeProjectScopes({
105 projects,
106 environmentId: remoteEnvironmentId,
107 projectGroupingMode: "separate",
108 });
109 const remoteScratch = remoteScopes.find((scope) => scope.representative.isScratch)!;
110 expect(remoteScratch.key).toBe(scratchScope.key);
111 expect(remoteScratch.projectRefs).toEqual([
112 { environmentId: remoteEnvironmentId, projectId: projects[1]!.id },
113 ]);
114 expect(
115 buildThreadListV2Items({
116 threads,
117 environmentId: remoteEnvironmentId,
118 projectRefs: remoteScratch.projectRefs,
119 searchQuery: "",
120 now: "2026-06-01T00:00:00.000Z",
121 }).items.map((item) => item.thread.id),
122 ).toEqual([threads[1]!.id]);
123 });
124
51125 it("builds one v2 scope for the same repository across environments", () => {
52126 const localEnvironmentId = EnvironmentId.make("environment-local");
53127 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
apps/mobile/src/features/home/homeThreadList.ts+1
......@@ -50,6 +50,7 @@ export function buildHomeProjectScopes(input: {
5050 );
5151 return buildProjectGroups({
5252 projects,
53 groupScratchProjects: true,
5354 settings: {
5455 sidebarProjectGroupingMode: input.projectGroupingMode,
5556 sidebarProjectGroupingOverrides: {},
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+11-1
......@@ -1,4 +1,5 @@
11import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
2import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping";
23import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
34import { computeThreadMoveAvailability } from "./threadOrder";
45import type {
......@@ -206,7 +207,16 @@ function ThreadNavigationSidebarPane(
206207 () => new Set(threadSearch.matches.map(threadSearchMatchKey)),
207208 [threadSearch.matches],
208209 );
209 const selectedProjectKey = options.selectedProjectKey;
210 const projectScopeKey = options.selectedProjectKey;
211 const selectedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, {
212 sidebarProjectGroupingMode: options.projectGroupingMode,
213 sidebarProjectGroupingOverrides: {},
214 });
215 useEffect(() => {
216 if (selectedProjectKey !== projectScopeKey) {
217 setSelectedProjectKey(selectedProjectKey);
218 }
219 }, [projectScopeKey, selectedProjectKey, setSelectedProjectKey]);
210220 const projectScopes = useMemo(
211221 () =>
212222 buildHomeProjectScopes({
apps/mobile/src/state/projects.ts+2
......@@ -4,10 +4,12 @@ import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/pro
44import { environmentCatalog } from "../connection/catalog";
55import { connectionAtomRuntime } from "../connection/runtime";
66import { environmentSnapshotAtom } from "./shell";
7import { serverEnvironment } from "./server";
78
89export const environmentProjects = createEnvironmentProjectAtoms({
910 catalogValueAtom: environmentCatalog.catalogValueAtom,
1011 snapshotAtom: environmentSnapshotAtom,
12 serverConfigValueAtom: serverEnvironment.configValueAtom,
1113});
1214export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, {
1315 projectAtom: environmentProjects.projectAtom,
apps/web/src/components/Sidebar.tsx+54-13
......@@ -1,4 +1,4 @@
1import { type EnvironmentId } from "@t3tools/contracts";
1import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts";
22import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard";
33import { CollapsibleSectionHeader } from "./ui/collapsible-section-header";
44import { setThreadChangeRequestSnapshot } from "./ThreadStatusIndicators";
......@@ -129,6 +129,8 @@ import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalPro
129129import {
130130 buildSidebarProjectSnapshots,
131131 projectGroupsSpanEnvironments,
132 resolveSidebarProjectScopeKey,
133 getSidebarProjectSettingsKey,
132134 type SidebarProjectSnapshot,
133135} from "../sidebarProjectGrouping";
134136import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
......@@ -158,6 +160,7 @@ import {
158160 useThreadShells,
159161} from "../state/entities";
160162import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server";
163import { environmentProjects } from "../state/projects";
161164import { vcsEnvironment } from "../state/vcs";
162165import { threadEnvironment } from "../state/threads";
163166import { useEnvironmentQuery } from "../state/query";
......@@ -2562,6 +2565,7 @@ export default function Sidebar() {
25622565 () =>
25632566 buildSidebarProjectSnapshots({
25642567 projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects,
2568 groupScratchProjects: true,
25652569 settings: projectGroupingSettings,
25662570 primaryEnvironmentId,
25672571 resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
......@@ -2625,8 +2629,17 @@ export default function Sidebar() {
26252629 // The selection lives in the persisted UI store next to the other sidebar
26262630 // project preferences, so routes that unmount the sidebar (Settings) and
26272631 // app restarts keep it.
2628 const projectScopeKey = useUiStateStore((store) => store.sidebarProjectScopeKey);
2632 const persistedProjectScopeKey = useUiStateStore((store) => store.sidebarProjectScopeKey);
26292633 const setProjectScopeKey = useUiStateStore((store) => store.setSidebarProjectScopeKey);
2634 const projectScopeKey = useMemo(
2635 () =>
2636 resolveSidebarProjectScopeKey({
2637 groups: projectGroups,
2638 key: persistedProjectScopeKey,
2639 settings: projectGroupingSettings,
2640 }),
2641 [persistedProjectScopeKey, projectGroups, projectGroupingSettings],
2642 );
26302643 // {value, label} items let Base UI drive the combobox selection contract
26312644 // while the popup search filters the same collection.
26322645 const projectScopeItems = useMemo(
......@@ -2709,15 +2722,28 @@ export default function Sidebar() {
27092722 ),
27102723 [scopedProjectGroup],
27112724 );
2712 // A persisted scope whose project is gone falls back to all projects, but
2713 // only after every catalog environment has a live project snapshot. Cached
2714 // or disconnected environments cannot establish that the project is gone.
2725 // Configs identify scratch projects, so both they and live project snapshots
2726 // must arrive before a persisted scope can be considered missing.
27152727 const allProjectSnapshotsReady = useAllEnvironmentProjectSnapshotsReady();
2728 const allProjectConfigsReady = useAtomValue(environmentProjects.projectConfigsReadyAtom);
27162729 useEffect(() => {
2717 if (projectScopeKey !== null && allProjectSnapshotsReady && scopedProjectGroup === null) {
2718 setProjectScopeKey(null);
2730 const nextKey = resolveSidebarProjectScopeKey({
2731 groups: projectGroups,
2732 key: persistedProjectScopeKey,
2733 settings: projectGroupingSettings,
2734 canClearMissingScope: allProjectSnapshotsReady && allProjectConfigsReady,
2735 });
2736 if (nextKey !== persistedProjectScopeKey) {
2737 setProjectScopeKey(nextKey);
27192738 }
2720 }, [allProjectSnapshotsReady, projectScopeKey, scopedProjectGroup, setProjectScopeKey]);
2739 }, [
2740 allProjectConfigsReady,
2741 allProjectSnapshotsReady,
2742 persistedProjectScopeKey,
2743 projectGroups,
2744 projectGroupingSettings,
2745 setProjectScopeKey,
2746 ]);
27212747 // Count-only subscription: the parent needs "are there draft rows" for the
27222748 // empty state, while SidebarDraftBlock owns the per-keystroke content
27232749 // subscription. Selecting a number keeps typing in a draft composer from
......@@ -2751,16 +2777,23 @@ export default function Sidebar() {
27512777 }, [clearSelection, projectScopeKey]);
27522778
27532779 const openProjectSettings = useCallback(
2754 (projectGroup: SidebarProjectSnapshot) => {
2780 (projectGroup: SidebarProjectSnapshot, projectRef?: ScopedProjectRef) => {
27552781 if (isMobile) {
27562782 setOpenMobile(false);
27572783 }
27582784 void router.navigate({
27592785 to: "/projects/$projectKey",
2760 params: { projectKey: projectGroup.projectKey },
2786 params: {
2787 projectKey: getSidebarProjectSettingsKey(
2788 projectGroup,
2789 projectGroupingSettings,
2790 projects,
2791 projectRef,
2792 ),
2793 },
27612794 });
27622795 },
2763 [isMobile, router, setOpenMobile],
2796 [isMobile, projectGroupingSettings, projects, router, setOpenMobile],
27642797 );
27652798 // Anchor for the scope popup: the header search field, not its icon trigger.
27662799 const headerSearchRef = useRef<HTMLDivElement | null>(null);
......@@ -4579,7 +4612,11 @@ export default function Sidebar() {
45794612 if (clicked._tag === "Failure") return;
45804613 switch (clicked.value) {
45814614 case "project-settings":
4582 if (projectGroup) openProjectSettings(projectGroup);
4615 if (projectGroup)
4616 openProjectSettings(projectGroup, {
4617 environmentId: session.environmentId,
4618 projectId: session.projectId,
4619 });
45834620 return;
45844621 case "copy-path":
45854622 if (workspacePath) copyPathToClipboard(workspacePath, { path: workspacePath });
......@@ -4703,7 +4740,11 @@ export default function Sidebar() {
47034740 }
47044741 return;
47054742 case "project-settings":
4706 if (threadProjectGroup) openProjectSettings(threadProjectGroup);
4743 if (threadProjectGroup)
4744 openProjectSettings(threadProjectGroup, {
4745 environmentId: threadRef.environmentId,
4746 projectId: thread.projectId,
4747 });
47074748 return;
47084749 case "new-thread-on-branch": {
47094750 // Explicit branch carry-over: reuse the thread's worktree when it
apps/web/src/environmentGrouping.test.ts+141
......@@ -13,6 +13,8 @@ import {
1313 buildSidebarProjectPickerEntries,
1414 buildSidebarProjectSnapshots,
1515 projectGroupsSpanEnvironments,
16 resolveSidebarProjectScopeKey,
17 getSidebarProjectSettingsKey,
1618} from "./sidebarProjectGrouping";
1719import { orderItemsByPreferredIds } from "./components/Sidebar.logic";
1820import { legacyProjectCwdPreferenceKey } from "./uiStateStore";
......@@ -52,6 +54,145 @@ function makeProject(overrides: Partial<Project> = {}): Project {
5254}
5355
5456describe("environment grouping", () => {
57 it("opens physical scratch settings from the merged filter, including a remote thread target", () => {
58 const local = makeProject({
59 title: "No project",
60 workspaceRoot: "/local/scratch",
61 isScratch: true,
62 });
63 const remote = makeProject({
64 title: "No project",
65 id: ProjectId.make("remote-scratch"),
66 environmentId: remoteEnvironmentId,
67 workspaceRoot: "/remote/scratch",
68 isScratch: true,
69 });
70 const input = {
71 projects: [local, remote],
72 settings: defaultGroupingSettings,
73 primaryEnvironmentId,
74 resolveEnvironmentLabel: () => null,
75 };
76 const filters = buildSidebarProjectSnapshots({ ...input, groupScratchProjects: true });
77 const settingsGroups = buildSidebarProjectSnapshots(input);
78 expect(filters).toHaveLength(1);
79 expect(settingsGroups).toHaveLength(2);
80 expect(settingsGroups.map((group) => group.memberProjectRefs)).toEqual([
81 [{ environmentId: local.environmentId, projectId: local.id }],
82 [{ environmentId: remote.environmentId, projectId: remote.id }],
83 ]);
84 expect(getSidebarProjectSettingsKey(filters[0]!, defaultGroupingSettings, input.projects)).toBe(
85 settingsGroups[0]?.projectKey,
86 );
87 expect(
88 getSidebarProjectSettingsKey(filters[0]!, defaultGroupingSettings, input.projects, {
89 environmentId: remote.environmentId,
90 projectId: remote.id,
91 }),
92 ).toBe(settingsGroups[1]?.projectKey);
93 });
94
95 it.each(["repository", "repository_path"] as const)(
96 "migrates a scratch scope selected before config in %s mode",
97 (mode) => {
98 const project = makeProject({
99 workspaceRoot: "/repo/scratch",
100 repositoryIdentity: { ...repositoryIdentity, rootPath: "/repo" },
101 });
102 const settings = { ...defaultGroupingSettings, sidebarProjectGroupingMode: mode };
103 const key = deriveLogicalProjectKeyFromSettings(project, settings);
104 const groups = buildSidebarProjectSnapshots({
105 projects: [{ ...project, isScratch: true }],
106 groupScratchProjects: true,
107 settings,
108 primaryEnvironmentId,
109 resolveEnvironmentLabel: () => null,
110 });
111 expect(
112 resolveSidebarProjectScopeKey({ groups, key, settings, canClearMissingScope: true }),
113 ).toBe(groups[0]?.projectKey);
114 },
115 );
116
117 it("prefers an existing ordinary repository scope over a scratch alias", () => {
118 const scratch = makeProject({ isScratch: true, repositoryIdentity });
119 const ordinary = makeProject({
120 id: ProjectId.make("ordinary"),
121 workspaceRoot: "/ordinary",
122 repositoryIdentity,
123 });
124 const groups = buildSidebarProjectSnapshots({
125 projects: [scratch, ordinary],
126 groupScratchProjects: true,
127 settings: defaultGroupingSettings,
128 primaryEnvironmentId,
129 resolveEnvironmentLabel: () => null,
130 });
131 const key = deriveLogicalProjectKeyFromSettings(ordinary, defaultGroupingSettings);
132 expect(resolveSidebarProjectScopeKey({ groups, key, canClearMissingScope: true })).toBe(key);
133 });
134
135 it("keeps both scratch hosts in one filter and migrates a physical scope after config loads", () => {
136 const projects = [
137 makeProject({ title: "No project", workspaceRoot: "/local/scratch" }),
138 makeProject({
139 id: ProjectId.make("scratch-remote"),
140 environmentId: remoteEnvironmentId,
141 title: "No project",
142 workspaceRoot: "/remote/scratch",
143 }),
144 ];
145 const build = (scratch: boolean) =>
146 buildSidebarProjectSnapshots({
147 projects: projects.map((project) => ({
148 ...project,
149 ...(scratch ? { isScratch: true as const } : {}),
150 })),
151 groupScratchProjects: true,
152 settings: defaultGroupingSettings,
153 primaryEnvironmentId,
154 resolveEnvironmentLabel: (id) => id,
155 });
156 const oldKey = derivePhysicalProjectKey(projects[1]!);
157 const unloaded = build(false);
158 const groups = build(true);
159 const scratchKey = groups[0]!.projectKey;
160 expect(groups).toHaveLength(1);
161 expect(groups[0]?.displayName).toBe("No project");
162 expect(groups[0]?.memberProjects).toHaveLength(2);
163 expect(groups[0]?.memberProjectRefs).toEqual(
164 projects.map((project) => ({ environmentId: project.environmentId, projectId: project.id })),
165 );
166 expect(resolveSidebarProjectScopeKey({ groups: unloaded, key: oldKey })).toBe(oldKey);
167 expect(resolveSidebarProjectScopeKey({ groups, key: oldKey, canClearMissingScope: true })).toBe(
168 scratchKey,
169 );
170 expect(resolveSidebarProjectScopeKey({ groups: unloaded, key: scratchKey })).toBe(scratchKey);
171 expect(
172 resolveSidebarProjectScopeKey({ groups, key: scratchKey, canClearMissingScope: true }),
173 ).toBe(scratchKey);
174 expect(
175 resolveSidebarProjectScopeKey({ groups, key: "removed", canClearMissingScope: true }),
176 ).toBeNull();
177 });
178
179 it("does not migrate an ordinary physical filter into a repository group", () => {
180 const project = makeProject({ repositoryIdentity, title: "No project" });
181 const groups = buildSidebarProjectSnapshots({
182 projects: [project],
183 settings: defaultGroupingSettings,
184 primaryEnvironmentId,
185 resolveEnvironmentLabel: () => null,
186 });
187 expect(
188 resolveSidebarProjectScopeKey({
189 groups,
190 key: derivePhysicalProjectKey(project),
191 canClearMissingScope: true,
192 }),
193 ).toBeNull();
194 });
195
55196 it("groups matching repository identities across environments", () => {
56197 const primary = makeProject({ repositoryIdentity });
57198 const remote = makeProject({
apps/web/src/sidebarProjectGrouping.ts+49-1
......@@ -1,5 +1,10 @@
11import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts";
2import { buildProjectGroups, type ProjectGroupingSettings } from "./logicalProject";
2import {
3 buildProjectGroups,
4 derivePhysicalProjectKey,
5 type ProjectGroupingSettings,
6} from "./logicalProject";
7import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping";
38import type { Project } from "./types";
49
510export type EnvironmentPresence = "local-only" | "remote-only" | "mixed";
......@@ -45,6 +50,47 @@ export interface SidebarProjectPickerEntry {
4550 isPreferred: boolean;
4651}
4752
53/** A merged filter still opens settings for the chosen physical project. */
54export function getSidebarProjectSettingsKey(
55 group: SidebarProjectSnapshot,
56 settings: ProjectGroupingSettings,
57 projects: ReadonlyArray<Project>,
58 projectRef?: ScopedProjectRef,
59): string {
60 if (!group.isScratch) return group.projectKey;
61 const project =
62 group.memberProjects.find(
63 (member) =>
64 member.environmentId === projectRef?.environmentId && member.id === projectRef.projectId,
65 ) ??
66 group.memberProjects.find((member) => member.environmentId === projectRef?.environmentId) ??
67 group;
68 return (
69 buildPhysicalToLogicalProjectKeyMap({ projects, settings, primaryEnvironmentId: null }).get(
70 derivePhysicalProjectKey(project),
71 ) ?? group.projectKey
72 );
73}
74
75/** Retains a saved physical scratch scope when its environment config coalesces the group. */
76export function resolveSidebarProjectScopeKey(input: {
77 readonly groups: ReadonlyArray<SidebarProjectSnapshot>;
78 readonly key: string | null;
79 readonly settings?: ProjectGroupingSettings;
80 readonly canClearMissingScope?: boolean;
81}): string | null {
82 if (input.key === null) return null;
83 const group =
84 input.groups.find((candidate) => candidate.projectKey === input.key) ??
85 input.groups.find(
86 (candidate) =>
87 candidate.isScratch &&
88 resolveScratchProjectScopeKey(input.key, candidate.memberProjects, input.settings) ===
89 candidate.projectKey,
90 );
91 return group?.projectKey ?? (input.canClearMissingScope ? null : input.key);
92}
93
4894export function buildPhysicalToLogicalProjectKeyMap(input: {
4995 projects: ReadonlyArray<Project>;
5096 settings: ProjectGroupingSettings;
......@@ -67,6 +113,7 @@ export function buildPhysicalToLogicalProjectKeyMap(input: {
67113export function buildSidebarProjectSnapshots(input: {
68114 projects: ReadonlyArray<Project>;
69115 settings: ProjectGroupingSettings;
116 groupScratchProjects?: boolean;
70117 primaryEnvironmentId: EnvironmentId | null;
71118 resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null;
72119 // Returns true when an env id maps to a desktop-local saved-env
......@@ -78,6 +125,7 @@ export function buildSidebarProjectSnapshots(input: {
78125}): SidebarProjectSnapshot[] {
79126 return buildProjectGroups({
80127 projects: input.projects,
128 groupScratchProjects: input.groupScratchProjects ?? false,
81129 settings: input.settings,
82130 preferredEnvironmentId: input.primaryEnvironmentId,
83131 }).map((group): SidebarProjectSnapshot => {
apps/web/src/state/projects.ts+2
......@@ -6,10 +6,12 @@ import { WS_METHODS } from "@t3tools/contracts";
66import { environmentCatalog } from "../connection/catalog";
77import { connectionAtomRuntime } from "../connection/runtime";
88import { environmentSnapshotAtom } from "./shell";
9import { serverEnvironment } from "./server";
910
1011export const environmentProjects = createEnvironmentProjectAtoms({
1112 catalogValueAtom: environmentCatalog.catalogValueAtom,
1213 snapshotAtom: environmentSnapshotAtom,
14 serverConfigValueAtom: serverEnvironment.configValueAtom,
1315});
1416export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, {
1517 projectAtom: environmentProjects.projectAtom,
packages/client-runtime/src/state/models.ts+2
......@@ -22,6 +22,8 @@ import { formatSubagentDisplayTitle } from "./subagentDisplay.ts";
2222
2323export interface EnvironmentProject extends OrchestrationProjectShell {
2424 readonly environmentId: EnvironmentId;
25 /** Derived from the environment's configured scratch root, never persisted. */
26 readonly isScratch?: true;
2527}
2628
2729/**
packages/client-runtime/src/state/projectEntities.test.ts created+135
......@@ -0,0 +1,135 @@
1import { EnvironmentId, ProjectId, type ServerConfig } from "@t3tools/contracts";
2import { Atom, AtomRegistry } from "effect/reactivity";
3import * as Option from "effect/Option";
4import { describe, expect, it } from "vite-plus/test";
5
6import { PrimaryConnectionTarget } from "../connection/model.ts";
7import { createEnvironmentProjectAtoms } from "./projectEntities.ts";
8import { v2Project, v2ShellSnapshot } from "./orchestrationV2TestFixtures.ts";
9
10const environmentId = EnvironmentId.make("local");
11const remoteEnvironmentId = EnvironmentId.make("remote");
12
13function makeHarness(withConfig = true) {
14 const snapshotAtom = Atom.family((_environmentId: EnvironmentId) =>
15 Atom.make({
16 ...v2ShellSnapshot,
17 projects: [
18 { ...v2Project, title: "No project" },
19 {
20 ...v2Project,
21 title: "No project",
22 id: ProjectId.make("ordinary"),
23 workspaceRoot: "/repo",
24 },
25 ],
26 }),
27 );
28 const configAtom = Atom.family((_environmentId: EnvironmentId) =>
29 Atom.make<(Pick<ServerConfig, "scratchWorkspaceRoot"> & { revision?: number }) | null>(null),
30 );
31 const catalogValueAtom = Atom.make({
32 isReady: true,
33 entries: new Map(
34 [environmentId, remoteEnvironmentId].map((id) => [
35 id,
36 {
37 target: new PrimaryConnectionTarget({
38 environmentId: id,
39 label: id,
40 httpBaseUrl: `https://${id}.example.test`,
41 wsBaseUrl: `wss://${id}.example.test`,
42 }),
43 profile: Option.none(),
44 enabled: true,
45 },
46 ]),
47 ),
48 });
49 const projects = createEnvironmentProjectAtoms({
50 catalogValueAtom,
51 snapshotAtom,
52 ...(withConfig ? { serverConfigValueAtom: configAtom } : {}),
53 });
54 return { registry: AtomRegistry.make(), projects, configAtom, snapshotAtom, catalogValueAtom };
55}
56
57describe("project scratch identity", () => {
58 it("waits for enabled project configs and ignores a disabled environment with no config", () => {
59 const { registry, projects, configAtom, catalogValueAtom } = makeHarness();
60 try {
61 expect(registry.get(projects.projectConfigsReadyAtom)).toBe(false);
62 registry.set(configAtom(environmentId), { scratchWorkspaceRoot: v2Project.workspaceRoot });
63 expect(registry.get(projects.projectConfigsReadyAtom)).toBe(false);
64 const catalog = registry.get(catalogValueAtom);
65 registry.set(catalogValueAtom, {
66 ...catalog,
67 entries: new Map(
68 [...catalog.entries].map(([id, entry]) => [
69 id,
70 { ...entry, enabled: id === environmentId },
71 ]),
72 ),
73 });
74 expect(registry.get(projects.projectConfigsReadyAtom)).toBe(true);
75 registry.set(catalogValueAtom, catalog);
76 expect(registry.get(projects.projectConfigsReadyAtom)).toBe(false);
77 registry.set(configAtom(remoteEnvironmentId), {});
78 expect(registry.get(projects.projectConfigsReadyAtom)).toBe(true);
79 registry.set(catalogValueAtom, { ...catalog, isReady: false });
80 expect(registry.get(projects.projectConfigsReadyAtom)).toBe(false);
81 } finally {
82 registry.dispose();
83 }
84 });
85
86 it("updates a loaded project when config arrives, preserving unrelated projects and snapshots", () => {
87 const { registry, projects, configAtom, snapshotAtom } = makeHarness();
88 try {
89 const initial = registry.get(projects.projectsAtom);
90 const source = registry.get(snapshotAtom(environmentId));
91 registry.set(configAtom(environmentId), {
92 scratchWorkspaceRoot: `${v2Project.workspaceRoot}/`,
93 });
94 const next = registry.get(projects.projectsAtom);
95
96 expect(next[0]?.isScratch).toBe(true);
97 expect(next[0]).not.toBe(initial[0]);
98 expect(next.slice(1)).toEqual(initial.slice(1));
99 for (let index = 1; index < initial.length; index++) {
100 expect(next[index]).toBe(initial[index]);
101 }
102 expect(registry.get(snapshotAtom(environmentId))).toBe(source);
103 expect(v2Project).not.toHaveProperty("isScratch");
104
105 registry.set(configAtom(environmentId), {
106 scratchWorkspaceRoot: v2Project.workspaceRoot,
107 revision: 1,
108 });
109 expect(registry.get(projects.projectsAtom)).toBe(next);
110
111 registry.set(configAtom(environmentId), { scratchWorkspaceRoot: "/repo" });
112 const changedRoot = registry.get(projects.projectsAtom);
113 expect(changedRoot[0]).not.toHaveProperty("isScratch");
114 expect(changedRoot[1]?.isScratch).toBe(true);
115 expect(changedRoot[2]).toBe(initial[2]);
116
117 registry.set(configAtom(environmentId), null);
118 expect(registry.get(projects.projectsAtom).every((project) => !project.isScratch)).toBe(true);
119 } finally {
120 registry.dispose();
121 }
122 });
123
124 it("keeps callers without server configs working without marking a same-title project", () => {
125 const { registry, projects, configAtom } = makeHarness(false);
126 try {
127 const initial = registry.get(projects.projectsAtom);
128 registry.set(configAtom(environmentId), { scratchWorkspaceRoot: v2Project.workspaceRoot });
129 expect(registry.get(projects.projectsAtom)).toBe(initial);
130 expect(initial.every((project) => !project.isScratch)).toBe(true);
131 } finally {
132 registry.dispose();
133 }
134 });
135});
packages/client-runtime/src/state/projectEntities.ts+35-2
......@@ -4,11 +4,13 @@ import type {
44 OrchestrationV2ShellSnapshot,
55 ProjectId,
66 ScopedProjectRef,
7 ServerConfig,
78} from "@t3tools/contracts";
89import { Atom } from "effect/reactivity";
910
1011import type { EnvironmentProject } from "./models.ts";
1112import { scopeProject } from "./models.ts";
13import { isScratchProject } from "./projects.ts";
1214import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts";
1315import { arrayElementsEqual, parseProjectKey, projectKey, projectRefsEqual } from "./entities.ts";
1416
......@@ -20,6 +22,9 @@ export function createEnvironmentProjectAtoms(input: {
2022 readonly snapshotAtom: (
2123 environmentId: EnvironmentId,
2224 ) => Atom.Atom<OrchestrationV2ShellSnapshot | null>;
25 readonly serverConfigValueAtom?: (
26 environmentId: EnvironmentId,
27 ) => Atom.Atom<Pick<ServerConfig, "scratchWorkspaceRoot"> | null>;
2328}) {
2429 const environmentProjectsAtom = Atom.family((environmentId: EnvironmentId) =>
2530 Atom.make(
......@@ -56,14 +61,30 @@ export function createEnvironmentProjectAtoms(input: {
5661 const projectAtomFamily = Atom.family((key: string) => {
5762 const ref = parseProjectKey(key);
5863 let previousSource: OrchestrationProjectShell | null = null;
64 let previousIsScratch = false;
5965 let previousValue: EnvironmentProject | null = null;
6066 return Atom.make((get) => {
6167 const source = get(environmentProjectIndexAtom(ref.environmentId)).get(ref.projectId) ?? null;
62 if (source === previousSource) {
68 const scratch =
69 source !== null &&
70 isScratchProject(
71 source,
72 input.serverConfigValueAtom
73 ? get(input.serverConfigValueAtom(ref.environmentId))?.scratchWorkspaceRoot
74 : null,
75 );
76 if (source === previousSource && scratch === previousIsScratch) {
6377 return previousValue;
6478 }
6579 previousSource = source;
66 previousValue = source === null ? null : scopeProject(ref.environmentId, source);
80 previousIsScratch = scratch;
81 previousValue =
82 source === null
83 ? null
84 : {
85 ...scopeProject(ref.environmentId, source),
86 ...(scratch ? { isScratch: true as const } : {}),
87 };
6788 return previousValue;
6889 }).pipe(Atom.withLabel(`environment-project:${key}`));
6990 });
......@@ -94,12 +115,24 @@ export function createEnvironmentProjectAtoms(input: {
94115 return previousProjects;
95116 }).pipe(Atom.withLabel("environment-project-list"));
96117
118 const projectConfigsReadyAtom = Atom.make((get) => {
119 const catalog = get(input.catalogValueAtom);
120 if (!catalog.isReady) return false;
121 for (const environmentId of enabledEnvironmentIds(catalog)) {
122 if (input.serverConfigValueAtom && get(input.serverConfigValueAtom(environmentId)) === null) {
123 return false;
124 }
125 }
126 return true;
127 }).pipe(Atom.withLabel("environment-project-configs-ready"));
128
97129 return {
98130 environmentProjectsAtom,
99131 environmentProjectIndexAtom,
100132 environmentProjectRefsAtom,
101133 projectRefsAtom,
102134 projectsAtom,
135 projectConfigsReadyAtom,
103136 projectAtom: (ref: ScopedProjectRef) => projectAtomFamily(projectKey(ref)),
104137 };
105138}
packages/client-runtime/src/state/projectGrouping.test.ts+127
......@@ -5,7 +5,9 @@ import type { EnvironmentProject } from "./models.ts";
55import { chooseLoadBalancedEnvironment } from "../load-balancing.ts";
66import {
77 buildProjectGroups,
8 deriveLogicalProjectKeyFromSettings,
89 derivePhysicalProjectKey,
10 resolveScratchProjectScopeKey,
911 type ProjectGroupingSettings,
1012} from "./projectGrouping.ts";
1113
......@@ -117,6 +119,131 @@ function settings(
117119}
118120
119121describe("buildProjectGroups", () => {
122 it.each(["repository", "repository_path", "separate"] as const)(
123 "keeps scratch project ownership separate by default in %s mode",
124 (mode) => {
125 const projects = [
126 makeProject("scratch-local", "/local/scratch", {
127 isScratch: true,
128 repositoryIdentity: null,
129 }),
130 makeProject("scratch-remote", "/remote/scratch", {
131 environmentId: EnvironmentId.make("remote"),
132 isScratch: true,
133 repositoryIdentity: null,
134 }),
135 ];
136 const groups = buildProjectGroups({ projects, settings: settings(mode) });
137 expect(groups.map((group) => group.key)).toEqual(projects.map(derivePhysicalProjectKey));
138 expect(groups.map((group) => group.memberProjectRefs)).toEqual(
139 projects.map((project) => [
140 { environmentId: project.environmentId, projectId: project.id },
141 ]),
142 );
143 expect(
144 buildProjectGroups({ projects, settings: settings(mode), groupScratchProjects: true }),
145 ).toHaveLength(1);
146 },
147 );
148
149 it.each(["repository", "repository_path", "separate"] as const)(
150 "migrates a pre-config %s scratch selection and retains it after the original host is removed",
151 (mode) => {
152 const local = makeProject("scratch-local", "/local/repo/scratch", {
153 repositoryIdentity: { ...repositoryIdentity, rootPath: "/local/repo" },
154 });
155 const remote = makeProject("scratch-remote", "/remote/scratch", {
156 environmentId: EnvironmentId.make("remote"),
157 repositoryIdentity: null,
158 isScratch: true,
159 });
160 const groupingSettings = settings(mode);
161 const selectedBeforeConfig = deriveLogicalProjectKeyFromSettings(local, groupingSettings);
162 const loaded = [{ ...local, isScratch: true as const }, remote];
163 const migrated = resolveScratchProjectScopeKey(
164 selectedBeforeConfig,
165 loaded,
166 groupingSettings,
167 );
168 const scratchKey = buildProjectGroups({
169 projects: loaded,
170 settings: groupingSettings,
171 groupScratchProjects: true,
172 })[0]!.key;
173 expect(migrated).toBe(scratchKey);
174 expect(resolveScratchProjectScopeKey(migrated, [remote], groupingSettings)).toBe(scratchKey);
175 expect(
176 buildProjectGroups({
177 projects: [remote],
178 settings: groupingSettings,
179 groupScratchProjects: true,
180 })[0]?.memberProjectRefs,
181 ).toEqual([{ environmentId: remote.environmentId, projectId: remote.id }]);
182 },
183 );
184
185 it("retains a repository filter that still belongs to an ordinary project", () => {
186 const scratch = makeProject("scratch", "/scratch", { isScratch: true });
187 const ordinary = makeProject("ordinary", "/ordinary");
188 const key = deriveLogicalProjectKeyFromSettings(ordinary, settings("repository"));
189 expect(resolveScratchProjectScopeKey(key, [scratch, ordinary])).toBe(key);
190 });
191
192 it.each(["repository", "repository_path", "separate"] as const)(
193 "coalesces scratch homes in %s mode without merging ordinary same-title projects",
194 (mode) => {
195 const remoteEnvironmentId = EnvironmentId.make("remote");
196 const projects = [
197 makeProject("scratch-local", "/local/scratch", {
198 title: "No project",
199 isScratch: true,
200 repositoryIdentity: null,
201 }),
202 makeProject("scratch-remote", "/remote/scratch", {
203 title: "No project",
204 environmentId: remoteEnvironmentId,
205 isScratch: true,
206 }),
207 makeProject("scratch-duplicate", "/remote/scratch/", {
208 title: "No project",
209 environmentId: remoteEnvironmentId,
210 isScratch: true,
211 repositoryIdentity: null,
212 updatedAt: "2026-07-02T00:00:00.000Z",
213 }),
214 makeProject("ordinary", "/ordinary", { title: "No project" }),
215 makeProject("reserved-collision", "/other", {
216 title: "No project",
217 repositoryIdentity: { ...repositoryIdentity, canonicalKey: "\0no-project" },
218 }),
219 ];
220 const groups = buildProjectGroups({
221 projects,
222 groupScratchProjects: true,
223 settings: settings(mode, { [derivePhysicalProjectKey(projects[0]!)]: "separate" }),
224 preferredEnvironmentId: remoteEnvironmentId,
225 });
226 expect(groups).toHaveLength(3);
227 const scratch = groups.find((group) => group.representative.isScratch)!;
228 expect(scratch.label).toBe("No project");
229 expect(scratch.representative.id).toBe(projects[2]?.id);
230 expect(scratch.members).toHaveLength(2);
231 expect(scratch.memberProjectRefs).toEqual(
232 projects.slice(0, 3).map((project) => ({
233 environmentId: project.environmentId,
234 projectId: project.id,
235 })),
236 );
237 expect(resolveScratchProjectScopeKey(derivePhysicalProjectKey(projects[1]!), projects)).toBe(
238 scratch.key,
239 );
240 expect(resolveScratchProjectScopeKey(derivePhysicalProjectKey(projects[3]!), projects)).toBe(
241 derivePhysicalProjectKey(projects[3]!),
242 );
243 expect(groups.filter((group) => !group.representative.isScratch)).toHaveLength(2);
244 },
245 );
246
120247 it("preserves every physical clone as a selectable member in repository modes", () => {
121248 const projects = [
122249 makeProject("t3code", "/work/t3code"),
packages/client-runtime/src/state/projectGrouping.ts+60-10
......@@ -11,6 +11,8 @@ import type { ClientSettings } from "@t3tools/contracts/settings";
1111import type { EnvironmentProject } from "./models.ts";
1212import { normalizeProjectPathForComparison } from "./projects.ts";
1313
14const SCRATCH_PROJECT_SCOPE_KEY = "\0no-project";
15
1416export interface ProjectGroupingSettings {
1517 readonly sidebarProjectGroupingMode: SidebarProjectGroupingMode;
1618 readonly sidebarProjectGroupingOverrides: Record<string, SidebarProjectGroupingMode>;
......@@ -105,7 +107,8 @@ function deriveRepositoryScopedKey(
105107 const canonicalKey = project.repositoryIdentity
106108 ? repositoryGroupingKeyOf(project.repositoryIdentity)
107109 : null;
108 if (!canonicalKey) {
110 // A NUL reserves the scratch key namespace outside valid repository identities.
111 if (!canonicalKey || canonicalKey.includes("\0")) {
109112 return null;
110113 }
111114
......@@ -126,12 +129,14 @@ function deriveRepositoryScopedKey(
126129export function deriveLogicalProjectKey(
127130 project: Pick<
128131 EnvironmentProject,
129 "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity"
132 "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch"
130133 >,
131134 options?: {
132135 readonly groupingMode?: SidebarProjectGroupingMode;
136 readonly groupScratchProjects?: boolean;
133137 },
134138): string {
139 if (options?.groupScratchProjects && project.isScratch) return SCRATCH_PROJECT_SCOPE_KEY;
135140 const groupingMode = options?.groupingMode ?? "repository";
136141 if (groupingMode === "separate") {
137142 return derivePhysicalProjectKey(project);
......@@ -147,15 +152,53 @@ export function deriveLogicalProjectKey(
147152export function deriveLogicalProjectKeyFromSettings(
148153 project: Pick<
149154 EnvironmentProject,
150 "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity"
155 "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch"
151156 >,
152157 settings: ProjectGroupingSettings,
158 options?: { readonly groupScratchProjects?: boolean },
153159): string {
154160 return deriveLogicalProjectKey(project, {
155161 groupingMode: resolveProjectGroupingMode(project, settings),
162 groupScratchProjects: options?.groupScratchProjects ?? false,
156163 });
157164}
158165
166/** Resolves a scratch filter selected before its environment config identified the project. */
167export function resolveScratchProjectScopeKey(
168 key: string | null,
169 projects: ReadonlyArray<
170 Pick<
171 EnvironmentProject,
172 "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch"
173 >
174 >,
175 settings: ProjectGroupingSettings = {
176 sidebarProjectGroupingMode: "repository",
177 sidebarProjectGroupingOverrides: {},
178 },
179): string | null {
180 if (key === null || key === SCRATCH_PROJECT_SCOPE_KEY) return key;
181 if (
182 projects.some(
183 (project) =>
184 deriveLogicalProjectKeyFromSettings(project, settings, { groupScratchProjects: true }) ===
185 key,
186 )
187 ) {
188 return key;
189 }
190 return projects.some((project) => {
191 if (!project.isScratch) return false;
192 const mode = resolveProjectGroupingMode(project, settings);
193 return (
194 derivePhysicalProjectKey(project) === key ||
195 (mode !== "separate" && deriveRepositoryScopedKey(project, mode) === key)
196 );
197 })
198 ? SCRATCH_PROJECT_SCOPE_KEY
199 : key;
200}
201
159202export function deriveProjectGroupLabel(input: {
160203 readonly representative: Pick<EnvironmentProject, "title" | "repositoryIdentity">;
161204 readonly members: ReadonlyArray<Pick<EnvironmentProject, "title" | "repositoryIdentity">>;
......@@ -254,6 +297,8 @@ export function buildProjectGroups<TProject extends EnvironmentProject>(input: {
254297 readonly projects: ReadonlyArray<TProject>;
255298 readonly settings: ProjectGroupingSettings;
256299 readonly preferredEnvironmentId?: EnvironmentId | null;
300 /** Filter equivalence does not change the default ownership of mutable project settings. */
301 readonly groupScratchProjects?: boolean;
257302}): ReadonlyArray<ProjectGroup<TProject>> {
258303 const projectsByPhysicalKey = new Map<string, TProject[]>();
259304 for (const project of input.projects) {
......@@ -275,6 +320,7 @@ export function buildProjectGroups<TProject extends EnvironmentProject>(input: {
275320 const identitySource = selectProjectIdentitySource(physicalProjects, winner);
276321 const logicalKey = deriveLogicalProjectKey(identitySource, {
277322 groupingMode: resolveProjectGroupingMode(winner, input.settings),
323 groupScratchProjects: input.groupScratchProjects ?? false,
278324 });
279325 logicalKeyByPhysicalKey.set(physicalProjectKey, logicalKey);
280326 const member = { physicalProjectKey, project: winner };
......@@ -292,7 +338,9 @@ export function buildProjectGroups<TProject extends EnvironmentProject>(input: {
292338 const physicalProjectKey = derivePhysicalProjectKey(project);
293339 const logicalKey =
294340 logicalKeyByPhysicalKey.get(physicalProjectKey) ??
295 deriveLogicalProjectKeyFromSettings(project, input.settings);
341 deriveLogicalProjectKeyFromSettings(project, input.settings, {
342 groupScratchProjects: input.groupScratchProjects ?? false,
343 });
296344 const projectRefKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id));
297345 if (seenProjectRefs.has(projectRefKey)) continue;
298346 seenProjectRefs.add(projectRefKey);
......@@ -314,12 +362,14 @@ export function buildProjectGroups<TProject extends EnvironmentProject>(input: {
314362 return {
315363 key,
316364 label:
317 members.length > 1
318 ? deriveProjectGroupLabel({
319 representative,
320 members: members.map((member) => member.project),
321 })
322 : representative.title,
365 input.groupScratchProjects && representative.isScratch
366 ? "No project"
367 : members.length > 1
368 ? deriveProjectGroupLabel({
369 representative,
370 members: members.map((member) => member.project),
371 })
372 : representative.title,
323373 representative,
324374 members,
325375 memberProjectRefs: projectRefsByLogicalKey.get(key) ?? [],