1import { EnvironmentId, ProjectId, ProviderInstanceId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 deriveLogicalProjectKey,
6 deriveLogicalProjectKeyFromSettings,
7 derivePhysicalProjectKey,
8 getProjectOrderKey,
9 resolveProjectGroupingMode,
10} from "./logicalProject";
11import {
12 buildPhysicalToLogicalProjectKeyMap,
13 buildSidebarProjectPickerEntries,
14 buildSidebarProjectSnapshots,
15 projectGroupsSpanEnvironments,
16 resolveSidebarProjectScopeKey,
17 getSidebarProjectSettingsKey,
18} from "./sidebarProjectGrouping";
19import { orderItemsByPreferredIds } from "./components/Sidebar.logic";
20import { legacyProjectCwdPreferenceKey } from "./uiStateStore";
21import type { Project } from "./types";
22
23const primaryEnvironmentId = EnvironmentId.make("env-primary");
24const remoteEnvironmentId = EnvironmentId.make("env-remote");
25const repositoryIdentity = {
26 canonicalKey: "github.com/example/shared-repo",
27 locator: {
28 source: "git-remote" as const,
29 remoteName: "origin",
30 remoteUrl: "https://github.com/example/shared-repo.git",
31 },
32};
33const defaultGroupingSettings = {
34 sidebarProjectGroupingMode: "repository" as const,
35 sidebarProjectGroupingOverrides: {},
36};
37
38function makeProject(overrides: Partial<Project> = {}): Project {
39 return {
40 id: ProjectId.make("project-1"),
41 environmentId: primaryEnvironmentId,
42 title: "shared-repo",
43 workspaceRoot: "/tmp/shared-repo",
44 repositoryIdentity: null,
45 defaultModelSelection: {
46 instanceId: ProviderInstanceId.make("codex"),
47 model: "gpt-5-codex",
48 },
49 createdAt: "2026-01-01T00:00:00.000Z",
50 updatedAt: "2026-01-01T00:00:00.000Z",
51 scripts: [],
52 ...overrides,
53 };
54}
55
56describe("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
196 it("groups matching repository identities across environments", () => {
197 const primary = makeProject({ repositoryIdentity });
198 const remote = makeProject({
199 id: ProjectId.make("project-remote"),
200 environmentId: remoteEnvironmentId,
201 repositoryIdentity,
202 });
203
204 expect(deriveLogicalProjectKey(primary)).toBe(repositoryIdentity.canonicalKey);
205 expect(deriveLogicalProjectKey(remote)).toBe(repositoryIdentity.canonicalKey);
206 });
207
208 it("counts cross-environment copies as one new-thread project choice", () => {
209 const primary = makeProject({ repositoryIdentity });
210 const remote = makeProject({
211 id: ProjectId.make("project-remote"),
212 environmentId: remoteEnvironmentId,
213 repositoryIdentity,
214 });
215
216 const projectGroupCount = buildSidebarProjectSnapshots({
217 projects: [primary, remote],
218 settings: defaultGroupingSettings,
219 primaryEnvironmentId,
220 resolveEnvironmentLabel: () => null,
221 }).length;
222
223 expect(projectGroupCount).toBe(1);
224 });
225
226 it("reports whether the project groups span more than one environment", () => {
227 const grouped = makeProject({ repositoryIdentity });
228 const groupedRemote = makeProject({
229 id: ProjectId.make("project-remote"),
230 environmentId: remoteEnvironmentId,
231 repositoryIdentity,
232 });
233 const separateLocal = makeProject({
234 id: ProjectId.make("workbench-local"),
235 title: "workbench",
236 workspaceRoot: "/tmp/workbench",
237 });
238 const separateRemote = makeProject({
239 id: ProjectId.make("workbench-remote"),
240 environmentId: remoteEnvironmentId,
241 title: "workbench",
242 workspaceRoot: "/tmp/workbench",
243 });
244 const build = (projects: Project[]) =>
245 buildSidebarProjectSnapshots({
246 projects,
247 settings: defaultGroupingSettings,
248 primaryEnvironmentId,
249 resolveEnvironmentLabel: (environmentId) =>
250 environmentId === remoteEnvironmentId ? "Mac mini" : "Primary",
251 });
252
253 const groups = build([groupedRemote, grouped, separateLocal, separateRemote]);
254 expect(groups).toHaveLength(3);
255 expect(projectGroupsSpanEnvironments(groups)).toBe(true);
256 expect(projectGroupsSpanEnvironments(build([grouped, separateLocal]))).toBe(false);
257 expect(projectGroupsSpanEnvironments(build([separateRemote]))).toBe(false);
258 });
259
260 it("keeps projects without repository identity physically scoped", () => {
261 const primary = makeProject();
262 const remote = makeProject({
263 id: ProjectId.make("project-remote"),
264 environmentId: remoteEnvironmentId,
265 });
266
267 expect(deriveLogicalProjectKey(primary)).toBe(derivePhysicalProjectKey(primary));
268 expect(deriveLogicalProjectKey(remote)).toBe(derivePhysicalProjectKey(remote));
269 expect(deriveLogicalProjectKey(primary)).not.toBe(deriveLogicalProjectKey(remote));
270 });
271
272 it("uses the physical key when repository grouping is disabled", () => {
273 const project = makeProject({ repositoryIdentity });
274
275 expect(
276 deriveLogicalProjectKeyFromSettings(project, {
277 sidebarProjectGroupingMode: "separate",
278 sidebarProjectGroupingOverrides: {},
279 }),
280 ).toBe(derivePhysicalProjectKey(project));
281 });
282
283 it("allows a per-project override to separate an otherwise grouped repository", () => {
284 const project = makeProject({ repositoryIdentity });
285 const physicalKey = derivePhysicalProjectKey(project);
286
287 expect(
288 deriveLogicalProjectKeyFromSettings(project, {
289 ...defaultGroupingSettings,
290 sidebarProjectGroupingOverrides: {
291 [physicalKey]: "separate",
292 },
293 }),
294 ).toBe(physicalKey);
295 });
296
297 it("allows a per-project override to group a repository while the global mode is separate", () => {
298 const project = makeProject({ repositoryIdentity });
299
300 expect(
301 deriveLogicalProjectKeyFromSettings(project, {
302 sidebarProjectGroupingMode: "separate",
303 sidebarProjectGroupingOverrides: {
304 [derivePhysicalProjectKey(project)]: "repository",
305 },
306 }),
307 ).toBe(repositoryIdentity.canonicalKey);
308 });
309
310 it("reports the effective grouping mode after applying an override", () => {
311 const project = makeProject({ repositoryIdentity });
312 const physicalKey = derivePhysicalProjectKey(project);
313
314 expect(resolveProjectGroupingMode(project, defaultGroupingSettings)).toBe("repository");
315 expect(
316 resolveProjectGroupingMode(project, {
317 ...defaultGroupingSettings,
318 sidebarProjectGroupingOverrides: {
319 [physicalKey]: "separate",
320 },
321 }),
322 ).toBe("separate");
323 });
324
325 it("dedupes stale project rows with the same environment and workspace path", () => {
326 const duplicate = makeProject({
327 id: ProjectId.make("project-duplicate"),
328 workspaceRoot: "/tmp/shared-repo/",
329 repositoryIdentity,
330 updatedAt: "2026-01-01T00:00:00.000Z",
331 });
332 const primary = makeProject({
333 id: ProjectId.make("project-primary"),
334 repositoryIdentity,
335 updatedAt: "2026-01-02T00:00:00.000Z",
336 });
337 const remote = makeProject({
338 id: ProjectId.make("project-remote"),
339 environmentId: remoteEnvironmentId,
340 workspaceRoot: "/tmp/shared-repo",
341 repositoryIdentity,
342 });
343
344 const snapshots = buildSidebarProjectSnapshots({
345 projects: [primary, duplicate, remote],
346 settings: defaultGroupingSettings,
347 primaryEnvironmentId,
348 resolveEnvironmentLabel: (environmentId) =>
349 environmentId === remoteEnvironmentId ? "remote" : "primary",
350 });
351
352 expect(snapshots).toHaveLength(1);
353 expect(snapshots[0]?.groupedProjectCount).toBe(2);
354 expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([
355 primary.id,
356 remote.id,
357 ]);
358 });
359
360 it("prefers the fresher project row when duplicate stale rows are ordered first", () => {
361 const staleDuplicate = makeProject({
362 id: ProjectId.make("project-stale"),
363 workspaceRoot: "/tmp/shared-repo/",
364 repositoryIdentity,
365 updatedAt: "2026-01-01T00:00:00.000Z",
366 });
367 const canonical = makeProject({
368 id: ProjectId.make("project-canonical"),
369 workspaceRoot: "/tmp/shared-repo",
370 repositoryIdentity,
371 updatedAt: "2026-01-02T00:00:00.000Z",
372 });
373
374 const snapshots = buildSidebarProjectSnapshots({
375 projects: [staleDuplicate, canonical],
376 settings: defaultGroupingSettings,
377 primaryEnvironmentId,
378 resolveEnvironmentLabel: () => "primary",
379 });
380
381 expect(snapshots).toHaveLength(1);
382 expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([canonical.id]);
383 expect(snapshots[0]?.id).toBe(canonical.id);
384 });
385
386 it("dedupes stale project rows before logical grouping", () => {
387 const staleWithoutRepositoryIdentity = makeProject({
388 id: ProjectId.make("project-stale"),
389 repositoryIdentity: null,
390 updatedAt: "2026-01-01T00:00:00.000Z",
391 });
392 const canonical = makeProject({
393 id: ProjectId.make("project-canonical"),
394 repositoryIdentity,
395 updatedAt: "2026-01-02T00:00:00.000Z",
396 });
397 const remote = makeProject({
398 id: ProjectId.make("project-remote"),
399 environmentId: remoteEnvironmentId,
400 repositoryIdentity,
401 });
402
403 const snapshots = buildSidebarProjectSnapshots({
404 projects: [staleWithoutRepositoryIdentity, canonical, remote],
405 settings: defaultGroupingSettings,
406 primaryEnvironmentId,
407 resolveEnvironmentLabel: (environmentId) =>
408 environmentId === remoteEnvironmentId ? "remote" : "primary",
409 });
410
411 expect(snapshots).toHaveLength(1);
412 expect(snapshots[0]?.projectKey).toBe(repositoryIdentity.canonicalKey);
413 expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([
414 canonical.id,
415 remote.id,
416 ]);
417 expect(snapshots[0]?.memberProjectRefs).toEqual([
418 {
419 environmentId: primaryEnvironmentId,
420 projectId: staleWithoutRepositoryIdentity.id,
421 },
422 { environmentId: primaryEnvironmentId, projectId: canonical.id },
423 { environmentId: remoteEnvironmentId, projectId: remote.id },
424 ]);
425
426 const [pickerEntry] = buildSidebarProjectPickerEntries({
427 groups: snapshots,
428 preferredProjectRef: {
429 environmentId: primaryEnvironmentId,
430 projectId: staleWithoutRepositoryIdentity.id,
431 },
432 });
433 expect(pickerEntry?.isPreferred).toBe(true);
434 expect(pickerEntry?.targetProject.id).toBe(canonical.id);
435 });
436
437 it("routes duplicate physical project keys to the winning logical group", () => {
438 const staleWithoutRepositoryIdentity = makeProject({
439 id: ProjectId.make("project-stale"),
440 repositoryIdentity: null,
441 updatedAt: "2026-01-01T00:00:00.000Z",
442 });
443 const canonical = makeProject({
444 id: ProjectId.make("project-canonical"),
445 repositoryIdentity,
446 updatedAt: "2026-01-02T00:00:00.000Z",
447 });
448
449 const physicalToLogicalKey = buildPhysicalToLogicalProjectKeyMap({
450 projects: [staleWithoutRepositoryIdentity, canonical],
451 settings: defaultGroupingSettings,
452 primaryEnvironmentId,
453 });
454
455 expect(physicalToLogicalKey.get(derivePhysicalProjectKey(staleWithoutRepositoryIdentity))).toBe(
456 repositoryIdentity.canonicalKey,
457 );
458 // Deriving from the stale project alone misses the identity its sibling
459 // carries, so consumers must go through the map to match the sidebar.
460 expect(
461 deriveLogicalProjectKeyFromSettings(staleWithoutRepositoryIdentity, defaultGroupingSettings),
462 ).not.toBe(repositoryIdentity.canonicalKey);
463 });
464
465 it("builds one picker entry per logical project and targets the preferred environment", () => {
466 const primary = makeProject({ repositoryIdentity });
467 const remote = makeProject({
468 id: ProjectId.make("project-remote"),
469 environmentId: remoteEnvironmentId,
470 repositoryIdentity,
471 });
472 const separate = makeProject({
473 id: ProjectId.make("project-separate"),
474 title: "separate",
475 workspaceRoot: "/tmp/separate",
476 });
477 const groups = buildSidebarProjectSnapshots({
478 projects: [separate, primary, remote],
479 settings: defaultGroupingSettings,
480 primaryEnvironmentId,
481 resolveEnvironmentLabel: () => null,
482 });
483
484 const entries = buildSidebarProjectPickerEntries({
485 groups,
486 preferredProjectRef: {
487 environmentId: remoteEnvironmentId,
488 projectId: remote.id,
489 },
490 });
491
492 expect(entries).toHaveLength(2);
493 expect(entries[0]?.group.projectKey).toBe(repositoryIdentity.canonicalKey);
494 expect(entries[0]?.targetProject).toMatchObject({
495 environmentId: remoteEnvironmentId,
496 id: remote.id,
497 });
498 expect(entries[0]?.isPreferred).toBe(true);
499 expect(entries[1]?.group.displayName).toBe("separate");
500 });
501
502 it("keeps the current environment when available and falls back otherwise", () => {
503 const currentPrimary = makeProject({ repositoryIdentity });
504 const currentRemote = makeProject({
505 id: ProjectId.make("current-remote"),
506 environmentId: remoteEnvironmentId,
507 repositoryIdentity,
508 });
509 const destinationRepositoryIdentity = {
510 canonicalKey: "github.com/example/destination",
511 locator: {
512 source: "git-remote" as const,
513 remoteName: "origin",
514 remoteUrl: "https://github.com/example/destination.git",
515 },
516 };
517 const destinationPrimary = makeProject({
518 id: ProjectId.make("destination-primary"),
519 title: "destination",
520 workspaceRoot: "/tmp/destination",
521 repositoryIdentity: destinationRepositoryIdentity,
522 });
523 const destinationRemote = makeProject({
524 id: ProjectId.make("destination-remote"),
525 environmentId: remoteEnvironmentId,
526 title: "destination",
527 workspaceRoot: "/remote/destination",
528 repositoryIdentity: destinationRepositoryIdentity,
529 });
530 const fallbackPrimary = makeProject({
531 id: ProjectId.make("fallback-primary"),
532 title: "fallback",
533 workspaceRoot: "/tmp/fallback",
534 });
535 const groups = buildSidebarProjectSnapshots({
536 projects: [
537 currentPrimary,
538 currentRemote,
539 destinationPrimary,
540 destinationRemote,
541 fallbackPrimary,
542 ],
543 settings: defaultGroupingSettings,
544 primaryEnvironmentId,
545 resolveEnvironmentLabel: () => null,
546 });
547
548 const entries = buildSidebarProjectPickerEntries({
549 groups,
550 preferredProjectRef: {
551 environmentId: remoteEnvironmentId,
552 projectId: currentRemote.id,
553 },
554 });
555 const destination = entries.find(
556 (entry) => entry.group.projectKey === destinationRepositoryIdentity.canonicalKey,
557 );
558 const fallback = entries.find((entry) => entry.group.displayName === "fallback");
559
560 expect(destination?.targetProject).toMatchObject({
561 environmentId: remoteEnvironmentId,
562 id: destinationRemote.id,
563 });
564 expect(fallback?.targetProject).toMatchObject({
565 environmentId: primaryEnvironmentId,
566 id: fallbackPrimary.id,
567 });
568 });
569
570 it("keeps manual project order when building grouped sidebar entries", () => {
571 const primary = makeProject({ repositoryIdentity });
572 const remote = makeProject({
573 id: ProjectId.make("project-remote"),
574 environmentId: remoteEnvironmentId,
575 repositoryIdentity,
576 });
577 const separate = makeProject({
578 id: ProjectId.make("project-separate"),
579 title: "separate",
580 workspaceRoot: "/tmp/separate",
581 });
582 const orderedProjects = orderItemsByPreferredIds({
583 items: [primary, remote, separate],
584 preferredIds: [getProjectOrderKey(separate), getProjectOrderKey(primary)],
585 getId: getProjectOrderKey,
586 getPreferenceIds: (project) => [
587 getProjectOrderKey(project),
588 legacyProjectCwdPreferenceKey(project.workspaceRoot),
589 ],
590 });
591
592 const groups = buildSidebarProjectSnapshots({
593 projects: orderedProjects,
594 settings: defaultGroupingSettings,
595 primaryEnvironmentId,
596 resolveEnvironmentLabel: () => null,
597 });
598
599 expect(groups.map((group) => group.displayName)).toEqual(["separate", "shared-repo"]);
600 });
601});