1import type {
2 EnvironmentProject,
3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";
5import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";
7
8import {
9 buildHomeProjectScopes,
10 findHomeProjectScope,
11 sortHomeProjectScopes,
12} from "./homeThreadList";
13import { makeThreadShellFixture } from "../../test-fixtures";
14import { buildThreadListV2Items } from "../threads/threadListV2";
15
16function makeProject(
17 input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">,
18): EnvironmentProject {
19 return {
20 workspaceRoot: `/workspaces/${input.id}`,
21 repositoryIdentity: null,
22 defaultModelSelection: null,
23 scripts: [],
24 createdAt: "2026-06-01T00:00:00.000Z",
25 updatedAt: "2026-06-01T00:00:00.000Z",
26 ...input,
27 };
28}
29
30function makeThread(
31 input: Partial<EnvironmentThreadShell> &
32 Pick<EnvironmentThreadShell, "environmentId" | "id" | "projectId" | "title">,
33): EnvironmentThreadShell {
34 return makeThreadShellFixture({
35 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
36 runtimeMode: "full-access",
37 interactionMode: "default",
38 branch: null,
39 worktreePath: null,
40 createdAt: "2026-06-01T00:00:00.000Z",
41 updatedAt: "2026-06-01T00:00:00.000Z",
42 archivedAt: null,
43 latestUserMessageAt: null,
44 hasPendingApprovals: false,
45 hasPendingUserInput: false,
46 hasActionableProposedPlan: false,
47 ...input,
48 });
49}
50
51describe("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
125 it("builds one v2 scope for the same repository across environments", () => {
126 const localEnvironmentId = EnvironmentId.make("environment-local");
127 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
128 const repositoryIdentity = {
129 canonicalKey: "github.com/pingdotgg/t3code",
130 locator: {
131 source: "git-remote" as const,
132 remoteName: "origin",
133 remoteUrl: "git@github.com:pingdotgg/t3code.git",
134 },
135 };
136 const projects = [
137 makeProject({
138 environmentId: localEnvironmentId,
139 id: ProjectId.make("project-local"),
140 title: "t3code",
141 repositoryIdentity,
142 }),
143 makeProject({
144 environmentId: remoteEnvironmentId,
145 id: ProjectId.make("project-remote"),
146 title: "t3code",
147 repositoryIdentity,
148 }),
149 ];
150
151 const scopes = buildHomeProjectScopes({
152 projects,
153 environmentId: null,
154 projectGroupingMode: "repository",
155 });
156
157 expect(scopes).toHaveLength(1);
158 expect(scopes[0]?.title).toBe("t3code");
159 expect(scopes[0]?.projects).toEqual(projects);
160 expect(scopes[0]?.projectRefs).toEqual(
161 projects.map((project) => ({
162 environmentId: project.environmentId,
163 projectId: project.id,
164 })),
165 );
166 });
167
168 it("keeps repository identity from an older duplicate when the freshness winner lacks it", () => {
169 const localEnvironmentId = EnvironmentId.make("environment-local");
170 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
171 const repositoryIdentity = {
172 canonicalKey: "github.com/pingdotgg/t3code",
173 locator: {
174 source: "git-remote" as const,
175 remoteName: "origin",
176 remoteUrl: "git@github.com:pingdotgg/t3code.git",
177 },
178 };
179 const projects = [
180 makeProject({
181 environmentId: localEnvironmentId,
182 id: ProjectId.make("project-local"),
183 title: "t3code",
184 repositoryIdentity,
185 }),
186 makeProject({
187 environmentId: remoteEnvironmentId,
188 id: ProjectId.make("project-remote-with-identity"),
189 title: "t3code",
190 workspaceRoot: "/remote/t3code",
191 repositoryIdentity,
192 updatedAt: "2026-06-01T00:00:00.000Z",
193 }),
194 makeProject({
195 environmentId: remoteEnvironmentId,
196 id: ProjectId.make("project-remote-fresh"),
197 title: "t3code",
198 workspaceRoot: "/remote/t3code/",
199 updatedAt: "2026-06-02T00:00:00.000Z",
200 }),
201 ];
202
203 const scopes = buildHomeProjectScopes({
204 projects,
205 environmentId: null,
206 projectGroupingMode: "repository",
207 });
208
209 expect(scopes).toHaveLength(1);
210 expect(scopes[0]?.representative.id).toBe(ProjectId.make("project-local"));
211 expect(scopes[0]?.projects.map((project) => project.id)).toContain(
212 ProjectId.make("project-remote-fresh"),
213 );
214 expect(scopes[0]?.projectRefs).toHaveLength(3);
215 });
216
217 it("sorts v2 project scopes by their grouped thread activity", () => {
218 const environmentId = EnvironmentId.make("environment-1");
219 const olderProject = makeProject({
220 environmentId,
221 id: ProjectId.make("project-older"),
222 title: "Older project",
223 });
224 const newerProject = makeProject({
225 environmentId,
226 id: ProjectId.make("project-newer"),
227 title: "Newer project",
228 });
229 const scopes = buildHomeProjectScopes({
230 projects: [newerProject, olderProject],
231 environmentId: null,
232 projectGroupingMode: "separate",
233 });
234
235 expect(
236 sortHomeProjectScopes({
237 scopes,
238 threads: [
239 makeThread({
240 environmentId,
241 id: ThreadId.make("thread-older-project"),
242 projectId: olderProject.id,
243 title: "Most recently active",
244 updatedAt: "2026-06-03T00:00:00.000Z",
245 }),
246 makeThread({
247 environmentId,
248 id: ThreadId.make("thread-newer-project"),
249 projectId: newerProject.id,
250 title: "Less recently active",
251 updatedAt: "2026-06-02T00:00:00.000Z",
252 }),
253 ],
254 pendingTasks: [],
255 projectSortOrder: "updated_at",
256 }).map((scope) => scope.representative.id),
257 ).toEqual([olderProject.id, newerProject.id]);
258 });
259
260 it("sorts invalid project creation timestamps after valid ones", () => {
261 const environmentId = EnvironmentId.make("environment-1");
262 const invalidProject = makeProject({
263 environmentId,
264 id: ProjectId.make("project-invalid"),
265 title: "A invalid timestamp",
266 createdAt: "invalid",
267 });
268 const validProject = makeProject({
269 environmentId,
270 id: ProjectId.make("project-valid"),
271 title: "Z valid timestamp",
272 createdAt: "2026-06-02T00:00:00.000Z",
273 });
274 const scopes = buildHomeProjectScopes({
275 projects: [invalidProject, validProject],
276 environmentId: null,
277 projectGroupingMode: "separate",
278 });
279
280 expect(
281 sortHomeProjectScopes({
282 scopes,
283 threads: [],
284 pendingTasks: [],
285 projectSortOrder: "created_at",
286 }).map((scope) => scope.representative.id),
287 ).toEqual([validProject.id, invalidProject.id]);
288 });
289
290 it("uses the freshest member when a grouped scope has no activity", () => {
291 const localEnvironmentId = EnvironmentId.make("environment-local");
292 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
293 const repositoryIdentity = {
294 canonicalKey: "github.com/pingdotgg/t3code",
295 locator: {
296 source: "git-remote" as const,
297 remoteName: "origin",
298 remoteUrl: "git@github.com:pingdotgg/t3code.git",
299 },
300 };
301 const olderMember = makeProject({
302 environmentId: localEnvironmentId,
303 id: ProjectId.make("project-older-member"),
304 title: "t3code",
305 updatedAt: "2026-06-01T00:00:00.000Z",
306 repositoryIdentity,
307 });
308 const newerMember = makeProject({
309 environmentId: remoteEnvironmentId,
310 id: ProjectId.make("project-newer-member"),
311 title: "t3code",
312 updatedAt: "2026-06-03T00:00:00.000Z",
313 repositoryIdentity,
314 });
315 const otherProject = makeProject({
316 environmentId: localEnvironmentId,
317 id: ProjectId.make("project-other"),
318 title: "other",
319 updatedAt: "2026-06-02T00:00:00.000Z",
320 });
321 const scopes = buildHomeProjectScopes({
322 projects: [olderMember, newerMember, otherProject],
323 environmentId: null,
324 projectGroupingMode: "repository",
325 });
326
327 expect(
328 sortHomeProjectScopes({
329 scopes,
330 threads: [],
331 pendingTasks: [],
332 projectSortOrder: "updated_at",
333 })[0]?.key,
334 ).toBe(scopes.find((scope) => scope.projects.length === 2)?.key);
335 });
336
337 it("does not merge unrelated repositories that share a title", () => {
338 const environmentId = EnvironmentId.make("environment-1");
339 const projects = ["one", "two"].map((name) =>
340 makeProject({
341 environmentId,
342 id: ProjectId.make(`project-${name}`),
343 title: "app",
344 repositoryIdentity: {
345 canonicalKey: `github.com/example/${name}`,
346 locator: {
347 source: "git-remote" as const,
348 remoteName: "origin",
349 remoteUrl: `git@github.com:example/${name}.git`,
350 },
351 },
352 }),
353 );
354
355 expect(
356 buildHomeProjectScopes({
357 projects,
358 environmentId: null,
359 projectGroupingMode: "repository",
360 }),
361 ).toHaveLength(2);
362 });
363
364 it("prefers an exact scope key over a project ID that collides with it", () => {
365 const environmentId = EnvironmentId.make("environment-local");
366 // project.create accepts caller-supplied IDs, so one project's scoped ref
367 // key can equal another project's normalized workspace-root scope key.
368 const collidingProject = makeProject({
369 environmentId,
370 id: ProjectId.make("/workspaces/target"),
371 title: "Colliding",
372 workspaceRoot: "/workspaces/colliding",
373 });
374 const targetProject = makeProject({
375 environmentId,
376 id: ProjectId.make("project-target"),
377 title: "Target",
378 workspaceRoot: "/workspaces/target",
379 });
380 const scopes = buildHomeProjectScopes({
381 projects: [collidingProject, targetProject],
382 environmentId: null,
383 projectGroupingMode: "separate",
384 });
385 const targetScope = scopes.find((scope) => scope.title === "Target");
386 expect(scopes.map((scope) => scope.title)).toEqual(["Colliding", "Target"]);
387 expect(targetScope?.key).toBe("environment-local:/workspaces/target");
388
389 expect(findHomeProjectScope(scopes, "environment-local:/workspaces/target")).toBe(targetScope);
390 expect(findHomeProjectScope(scopes, "environment-local:project-target")).toBe(targetScope);
391 expect(findHomeProjectScope(scopes, "environment-local:missing")).toBeNull();
392 expect(findHomeProjectScope(scopes, null)).toBeNull();
393 });
394});