Skip to content
Source
Sign inSign up

homeThreadList.test.ts

225 lines7.5 KB

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 { buildHomeProjectScopes, findHomeProjectScope } from "./homeThreadList";
9import { makeThreadShellFixture } from "../../test-fixtures";
10import { buildThreadListV2Items } from "../threads/threadListV2";
11​
12function makeProject(
13 input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">,
14): EnvironmentProject {
15 return {
16 workspaceRoot: `/workspaces/${input.id}`,
17 repositoryIdentity: null,
18 defaultModelSelection: null,
19 scripts: [],
20 createdAt: "2026-06-01T00:00:00.000Z",
21 updatedAt: "2026-06-01T00:00:00.000Z",
22 ...input,
23 };
25​
26function makeThread(
27 input: Partial<EnvironmentThreadShell> &
28 Pick<EnvironmentThreadShell, "environmentId" | "id" | "projectId" | "title">,
29): EnvironmentThreadShell {
30 return makeThreadShellFixture({
31 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
32 runtimeMode: "full-access",
33 interactionMode: "default",
34 branch: null,
35 worktreePath: null,
36 createdAt: "2026-06-01T00:00:00.000Z",
37 updatedAt: "2026-06-01T00:00:00.000Z",
38 archivedAt: null,
39 latestUserMessageAt: null,
40 hasPendingApprovals: false,
41 hasPendingUserInput: false,
42 hasActionableProposedPlan: false,
43 ...input,
44 });
46​
47describe("home project scopes", () => {
48 it("filters threads across both scratch hosts while retaining the selected environment boundary", () => {
49 const localEnvironmentId = EnvironmentId.make("local");
50 const remoteEnvironmentId = EnvironmentId.make("remote");
51 const projects = [
52 makeProject({
53 environmentId: localEnvironmentId,
54 id: ProjectId.make("scratch-local"),
55 title: "No project",
56 workspaceRoot: "/local/scratch",
57 isScratch: true,
58 }),
59 makeProject({
60 environmentId: remoteEnvironmentId,
61 id: ProjectId.make("scratch-remote"),
62 title: "No project",
63 workspaceRoot: "/remote/scratch",
64 isScratch: true,
65 }),
66 makeProject({
67 environmentId: remoteEnvironmentId,
68 id: ProjectId.make("ordinary"),
69 title: "No project",
70 }),
71 ];
72 const threads = projects.map((project) =>
73 makeThread({
74 environmentId: project.environmentId,
75 projectId: project.id,
76 id: ThreadId.make(`thread-${project.id}`),
77 title: project.id,
78 }),
79 );
80 const scopes = buildHomeProjectScopes({
81 projects,
82 environmentId: null,
83 projectGroupingMode: "separate",
84 });
85 expect(scopes).toHaveLength(2);
86 const scratchScope = scopes.find((scope) => scope.representative.isScratch)!;
87 const filtered = buildThreadListV2Items({
88 threads,
89 environmentId: null,
90 projectRefs: scratchScope.projectRefs,
91 searchQuery: "",
92 now: "2026-06-01T00:00:00.000Z",
93 });
94 expect(filtered.items.map((item) => item.thread.id).sort()).toEqual(
95 threads
96 .slice(0, 2)
97 .map((thread) => thread.id)
98 .sort(),
99 );
100 const remoteScopes = buildHomeProjectScopes({
101 projects,
102 environmentId: remoteEnvironmentId,
103 projectGroupingMode: "separate",
104 });
105 const remoteScratch = remoteScopes.find((scope) => scope.representative.isScratch)!;
106 expect(remoteScratch.key).toBe(scratchScope.key);
107 expect(remoteScratch.projectRefs).toEqual([
108 { environmentId: remoteEnvironmentId, projectId: projects[1]!.id },
109 ]);
110 expect(
111 buildThreadListV2Items({
112 threads,
113 environmentId: remoteEnvironmentId,
114 projectRefs: remoteScratch.projectRefs,
115 searchQuery: "",
116 now: "2026-06-01T00:00:00.000Z",
117 }).items.map((item) => item.thread.id),
118 ).toEqual([threads[1]!.id]);
119 });
120​
121 it("keeps repository identity from an older duplicate when the freshness winner lacks it", () => {
122 const localEnvironmentId = EnvironmentId.make("environment-local");
123 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
124 const repositoryIdentity = {
125 canonicalKey: "github.com/pingdotgg/t3code",
126 locator: {
127 source: "git-remote" as const,
128 remoteName: "origin",
129 remoteUrl: "git@github.com:pingdotgg/t3code.git",
130 },
131 };
132 const projects = [
133 makeProject({
134 environmentId: localEnvironmentId,
135 id: ProjectId.make("project-local"),
136 title: "t3code",
137 repositoryIdentity,
138 }),
139 makeProject({
140 environmentId: remoteEnvironmentId,
141 id: ProjectId.make("project-remote-with-identity"),
142 title: "t3code",
143 workspaceRoot: "/remote/t3code",
144 repositoryIdentity,
145 updatedAt: "2026-06-01T00:00:00.000Z",
146 }),
147 makeProject({
148 environmentId: remoteEnvironmentId,
149 id: ProjectId.make("project-remote-fresh"),
150 title: "t3code",
151 workspaceRoot: "/remote/t3code/",
152 updatedAt: "2026-06-02T00:00:00.000Z",
153 }),
154 ];
155​
156 const scopes = buildHomeProjectScopes({
157 projects,
158 environmentId: null,
159 projectGroupingMode: "repository",
160 });
161​
162 expect(scopes).toHaveLength(1);
163 expect(scopes[0]?.representative.id).toBe(ProjectId.make("project-local"));
164 expect(scopes[0]?.projects.map((project) => project.id)).toContain(
165 ProjectId.make("project-remote-fresh"),
166 );
167 expect(scopes[0]?.projectRefs).toHaveLength(3);
168 });
169​
170 it("does not merge unrelated repositories that share a title", () => {
171 const environmentId = EnvironmentId.make("environment-1");
172 const projects = ["one", "two"].map((name) =>
173 makeProject({
174 environmentId,
175 id: ProjectId.make(`project-${name}`),
176 title: "app",
177 repositoryIdentity: {
178 canonicalKey: `github.com/example/${name}`,
179 locator: {
180 source: "git-remote" as const,
181 remoteName: "origin",
182 remoteUrl: `git@github.com:example/${name}.git`,
183 },
184 },
185 }),
186 );
187​
188 expect(
189 buildHomeProjectScopes({
190 projects,
191 environmentId: null,
192 projectGroupingMode: "repository",
193 }),
194 ).toHaveLength(2);
195 });
196​
197 it("prefers an exact scope key over a project ID that collides with it", () => {
198 const environmentId = EnvironmentId.make("environment-local");
199 const collidingProject = makeProject({
200 environmentId,
201 id: ProjectId.make("/workspaces/target"),
202 title: "Colliding",
203 workspaceRoot: "/workspaces/colliding",
204 });
205 const targetProject = makeProject({
206 environmentId,
207 id: ProjectId.make("project-target"),
208 title: "Target",
209 workspaceRoot: "/workspaces/target",
210 });
211 const scopes = buildHomeProjectScopes({
212 projects: [collidingProject, targetProject],
213 environmentId: null,
214 projectGroupingMode: "separate",
215 });
216 const targetScope = scopes.find((scope) => scope.title === "Target");
217 expect(scopes.map((scope) => scope.title)).toEqual(["Colliding", "Target"]);
218 expect(targetScope?.key).toBe("environment-local:/workspaces/target");
219​
220 expect(findHomeProjectScope(scopes, "environment-local:/workspaces/target")).toBe(targetScope);
221 expect(findHomeProjectScope(scopes, "environment-local:project-target")).toBe(targetScope);
222 expect(findHomeProjectScope(scopes, "environment-local:missing")).toBeNull();
223 expect(findHomeProjectScope(scopes, null)).toBeNull();
224 });
225});