authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-23 18:06:38+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-23 18:06:38+02:00
log0542abc75e378428165cf8a492d02dd4cc3dc348
treef8cd6b65f80b1c1cb077f755d6bd47d280565f56
parent7609495bf70a51dd4d8608e3f71da636a7348aa6
signature Signed by PGP key B5690EEEBB952194

[codex] Group project scopes in mobile thread lists (#4314)

Co-authored-by: codex <codex@users.noreply.github.com>

6 files changed, 631 insertions(+), 103 deletions(-)

apps/mobile/src/features/home/HomeScreen.tsx+52-24
...@@ -15,17 +15,15 @@ import type {...@@ -15,17 +15,15 @@ import type {
15import { useAtomSet, useAtomValue } from "@effect/atom-react";15import { useAtomSet, useAtomValue } from "@effect/atom-react";
16import { AsyncResult } from "effect/unstable/reactivity";16import { AsyncResult } from "effect/unstable/reactivity";
17import { useCallback, useEffect, useMemo, useRef, useState } from "react";17import { useCallback, useEffect, useMemo, useRef, useState } from "react";
18import { ActivityIndicator, FlatList, Platform, Pressable, ScrollView, View } from "react-native";18import { ActivityIndicator, FlatList, Platform, Pressable, View } from "react-native";
19import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";19import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
20import { useSafeAreaInsets } from "react-native-safe-area-context";20import { useSafeAreaInsets } from "react-native-safe-area-context";
21import { useThemeColor } from "../../lib/useThemeColor";21import { useThemeColor } from "../../lib/useThemeColor";
2222
23import { AppText as Text } from "../../components/AppText";23import { AppText as Text } from "../../components/AppText";
24import { EmptyState } from "../../components/EmptyState";24import { EmptyState } from "../../components/EmptyState";
25import { ProjectFavicon } from "../../components/ProjectFavicon";
26import type { WorkspaceState } from "../../state/workspaceModel";25import type { WorkspaceState } from "../../state/workspaceModel";
27import type { SavedRemoteConnection } from "../../lib/connection";26import type { SavedRemoteConnection } from "../../lib/connection";
28import { cn } from "../../lib/cn";
29import { scopedProjectKey } from "../../lib/scopedEntities";27import { scopedProjectKey } from "../../lib/scopedEntities";
30import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";28import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
31import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";29import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
...@@ -54,7 +52,12 @@ import {...@@ -54,7 +52,12 @@ import {
54 type HomeGroupDisplayState,52 type HomeGroupDisplayState,
55 type HomeListItem,53 type HomeListItem,
56} from "./homeListItems";54} from "./homeListItems";
57import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList";55import {
56 buildHomeProjectScopes,
57 buildHomeThreadGroups,
58 sortHomeProjectScopes,
59 type HomeProjectSortOrder,
60} from "./homeThreadList";
58import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";61import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";
59import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus";62import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus";
60import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status";63import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status";
...@@ -334,23 +337,53 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -334,23 +337,53 @@ export function HomeScreen(props: HomeScreenProps) {
334 }, [props.projects]);337 }, [props.projects]);
335338
336 const v2ProjectScopeKey = props.selectedProjectKey;339 const v2ProjectScopeKey = props.selectedProjectKey;
337 const setV2ProjectScopeKey = props.onProjectChange;
338 const v2ScopeProjects = useMemo(340 const v2ScopeProjects = useMemo(
339 () =>341 () =>
340 props.selectedEnvironmentId === null342 sortHomeProjectScopes({
341 ? props.projects343 scopes: buildHomeProjectScopes({
342 : props.projects.filter((project) => project.environmentId === props.selectedEnvironmentId),344 projects: props.projects,
343 [props.projects, props.selectedEnvironmentId],345 environmentId: props.selectedEnvironmentId,
346 projectGroupingMode: props.projectGroupingMode,
347 }),
348 threads: props.threads,
349 pendingTasks: props.pendingTasks,
350 projectSortOrder: props.projectSortOrder,
351 }),
352 [
353 props.pendingTasks,
354 props.projectGroupingMode,
355 props.projects,
356 props.projectSortOrder,
357 props.selectedEnvironmentId,
358 props.threads,
359 ],
344 );360 );
345 const v2ScopedProject = useMemo(361 const v2ScopedProjectGroup = useMemo(
346 () =>362 () =>
347 v2ProjectScopeKey === null363 v2ProjectScopeKey === null
348 ? null364 ? null
349 : (v2ScopeProjects.find(365 : (v2ScopeProjects.find(
350 (project) => scopedProjectKey(project.environmentId, project.id) === v2ProjectScopeKey,366 (scope) =>
367 scope.key === v2ProjectScopeKey ||
368 scope.projectRefs.some(
369 (projectRef) =>
370 scopedProjectKey(projectRef.environmentId, projectRef.projectId) ===
371 v2ProjectScopeKey,
372 ),
351 ) ?? null),373 ) ?? null),
352 [v2ProjectScopeKey, v2ScopeProjects],374 [v2ProjectScopeKey, v2ScopeProjects],
353 );375 );
376 const v2ScopedProjectKeys = useMemo(
377 () =>
378 v2ScopedProjectGroup === null
379 ? null
380 : new Set(
381 v2ScopedProjectGroup.projectRefs.map((projectRef) =>
382 scopedProjectKey(projectRef.environmentId, projectRef.projectId),
383 ),
384 ),
385 [v2ScopedProjectGroup],
386 );
354 // Thread List v2 (beta): one flat list in creation order, no grouping.387 // Thread List v2 (beta): one flat list in creation order, no grouping.
355 // Settled threads collapse into a recency tail below the card block.388 // Settled threads collapse into a recency tail below the card block.
356 // Settled threads stay in the live shell stream (settled ≠ archived), so389 // Settled threads stay in the live shell stream (settled ≠ archived), so
...@@ -431,13 +464,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -431,13 +464,7 @@ export function HomeScreen(props: HomeScreenProps) {
431 return buildThreadListV2Items({464 return buildThreadListV2Items({
432 threads: props.threads.filter((thread) => thread.archivedAt === null),465 threads: props.threads.filter((thread) => thread.archivedAt === null),
433 environmentId: props.selectedEnvironmentId,466 environmentId: props.selectedEnvironmentId,
434 projectRef:467 projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
435 v2ScopedProject === null
436 ? null
437 : {
438 environmentId: v2ScopedProject.environmentId,
439 projectId: v2ScopedProject.id,
440 },
441 searchQuery: props.searchQuery,468 searchQuery: props.searchQuery,
442 changeRequestStateByKey,469 changeRequestStateByKey,
443 settlementEnvironmentIds,470 settlementEnvironmentIds,
...@@ -453,7 +480,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -453,7 +480,7 @@ export function HomeScreen(props: HomeScreenProps) {
453 props.selectedEnvironmentId,480 props.selectedEnvironmentId,
454 props.threads,481 props.threads,
455 threadListV2Enabled,482 threadListV2Enabled,
456 v2ScopedProject,483 v2ScopedProjectGroup,
457 ]);484 ]);
458 const threadListV2Items = threadListV2Layout.items;485 const threadListV2Items = threadListV2Layout.items;
459486
...@@ -699,9 +726,10 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -699,9 +726,10 @@ export function HomeScreen(props: HomeScreenProps) {
699 (pendingTask) =>726 (pendingTask) =>
700 (props.selectedEnvironmentId === null ||727 (props.selectedEnvironmentId === null ||
701 pendingTask.message.environmentId === props.selectedEnvironmentId) &&728 pendingTask.message.environmentId === props.selectedEnvironmentId) &&
702 (v2ScopedProject === null ||729 (v2ScopedProjectKeys === null ||
703 (pendingTask.message.environmentId === v2ScopedProject.environmentId &&730 v2ScopedProjectKeys.has(
704 pendingTask.creation.projectId === v2ScopedProject.id)) &&731 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
732 )) &&
705 (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),733 (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
706 );734 );
707 // Project scoping lives in the header filter menu (no inline chip row on735 // Project scoping lives in the header filter menu (no inline chip row on
...@@ -750,9 +778,9 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -750,9 +778,9 @@ export function HomeScreen(props: HomeScreenProps) {
750 const v2ListEmpty =778 const v2ListEmpty =
751 v2PendingTasks.length > 0 ? null : hasSearchQuery ? (779 v2PendingTasks.length > 0 ? null : hasSearchQuery ? (
752 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />780 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
753 ) : v2ScopedProject !== null ? (781 ) : v2ScopedProjectGroup !== null ? (
754 <EmptyState782 <EmptyState
755 title={`No threads in ${v2ScopedProject.title}`}783 title={`No threads in ${v2ScopedProjectGroup.title}`}
756 detail="Choose another project or create a new task."784 detail="Choose another project or create a new task."
757 />785 />
758 ) : (786 ) : (
apps/mobile/src/features/home/homeThreadList.test.ts+307-1
...@@ -5,7 +5,11 @@ import type {...@@ -5,7 +5,11 @@ import type {
5import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";5import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";6import { describe, expect, it } from "vite-plus/test";
77
8import { buildHomeThreadGroups } from "./homeThreadList";8import {
9 buildHomeProjectScopes,
10 buildHomeThreadGroups,
11 sortHomeProjectScopes,
12} from "./homeThreadList";
913
10function makeProject(14function makeProject(
11 input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">,15 input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">,
...@@ -67,6 +71,308 @@ function buildGroups(...@@ -67,6 +71,308 @@ function buildGroups(
67}71}
6872
69describe("buildHomeThreadGroups", () => {73describe("buildHomeThreadGroups", () => {
74 it("builds one v2 scope for the same repository across environments", () => {
75 const localEnvironmentId = EnvironmentId.make("environment-local");
76 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
77 const repositoryIdentity = {
78 canonicalKey: "github.com/pingdotgg/t3code",
79 locator: {
80 source: "git-remote" as const,
81 remoteName: "origin",
82 remoteUrl: "git@github.com:pingdotgg/t3code.git",
83 },
84 };
85 const projects = [
86 makeProject({
87 environmentId: localEnvironmentId,
88 id: ProjectId.make("project-local"),
89 title: "t3code",
90 repositoryIdentity,
91 }),
92 makeProject({
93 environmentId: remoteEnvironmentId,
94 id: ProjectId.make("project-remote"),
95 title: "t3code",
96 repositoryIdentity,
97 }),
98 ];
99
100 const scopes = buildHomeProjectScopes({
101 projects,
102 environmentId: null,
103 projectGroupingMode: "repository",
104 });
105
106 expect(scopes).toHaveLength(1);
107 expect(scopes[0]?.projects).toEqual(projects);
108 expect(scopes[0]?.projectRefs).toEqual(
109 projects.map((project) => ({
110 environmentId: project.environmentId,
111 projectId: project.id,
112 })),
113 );
114 });
115
116 it("routes stale duplicate project refs through the canonical repository group", () => {
117 const localEnvironmentId = EnvironmentId.make("environment-local");
118 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
119 const repositoryIdentity = {
120 canonicalKey: "github.com/pingdotgg/t3code",
121 locator: {
122 source: "git-remote" as const,
123 remoteName: "origin",
124 remoteUrl: "git@github.com:pingdotgg/t3code.git",
125 },
126 };
127 const local = makeProject({
128 id: ProjectId.make("project-local"),
129 environmentId: localEnvironmentId,
130 title: "t3code",
131 workspaceRoot: "/workspaces/t3code",
132 repositoryIdentity,
133 });
134 const stale = makeProject({
135 environmentId: remoteEnvironmentId,
136 id: ProjectId.make("project-stale"),
137 title: "t3code",
138 workspaceRoot: "/remote/t3code",
139 updatedAt: "2026-06-01T00:00:00.000Z",
140 });
141 const canonicalRemote = makeProject({
142 environmentId: remoteEnvironmentId,
143 id: ProjectId.make("project-canonical-remote"),
144 title: "t3code",
145 workspaceRoot: "/remote/t3code/",
146 repositoryIdentity,
147 updatedAt: "2026-06-02T00:00:00.000Z",
148 });
149 const projects = [local, stale, canonicalRemote];
150 const staleThread = makeThread({
151 environmentId: remoteEnvironmentId,
152 id: ThreadId.make("thread-stale-project-ref"),
153 projectId: stale.id,
154 title: "Still visible",
155 updatedAt: "2026-06-03T00:00:00.000Z",
156 });
157
158 const scopes = buildHomeProjectScopes({
159 projects,
160 environmentId: null,
161 projectGroupingMode: "repository",
162 });
163 const groups = buildGroups(projects, [staleThread]);
164
165 expect(scopes).toHaveLength(1);
166 expect(scopes[0]?.projects.map((project) => project.id)).toEqual([
167 local.id,
168 canonicalRemote.id,
169 ]);
170 expect(scopes[0]?.projectRefs.map((projectRef) => projectRef.projectId)).toEqual([
171 local.id,
172 stale.id,
173 canonicalRemote.id,
174 ]);
175 expect(groups).toHaveLength(1);
176 expect(groups[0]?.threads.map((thread) => thread.id)).toEqual([staleThread.id]);
177 expect(groups[0]?.newThreadTarget?.id).toBe(canonicalRemote.id);
178 });
179
180 it("keeps repository identity from an older duplicate when the freshness winner lacks it", () => {
181 const localEnvironmentId = EnvironmentId.make("environment-local");
182 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
183 const repositoryIdentity = {
184 canonicalKey: "github.com/pingdotgg/t3code",
185 locator: {
186 source: "git-remote" as const,
187 remoteName: "origin",
188 remoteUrl: "git@github.com:pingdotgg/t3code.git",
189 },
190 };
191 const projects = [
192 makeProject({
193 environmentId: localEnvironmentId,
194 id: ProjectId.make("project-local"),
195 title: "t3code",
196 repositoryIdentity,
197 }),
198 makeProject({
199 environmentId: remoteEnvironmentId,
200 id: ProjectId.make("project-remote-with-identity"),
201 title: "t3code",
202 workspaceRoot: "/remote/t3code",
203 repositoryIdentity,
204 updatedAt: "2026-06-01T00:00:00.000Z",
205 }),
206 makeProject({
207 environmentId: remoteEnvironmentId,
208 id: ProjectId.make("project-remote-fresh"),
209 title: "t3code",
210 workspaceRoot: "/remote/t3code/",
211 updatedAt: "2026-06-02T00:00:00.000Z",
212 }),
213 ];
214
215 const scopes = buildHomeProjectScopes({
216 projects,
217 environmentId: null,
218 projectGroupingMode: "repository",
219 });
220
221 expect(scopes).toHaveLength(1);
222 expect(scopes[0]?.representative.id).toBe(ProjectId.make("project-local"));
223 expect(scopes[0]?.projects.map((project) => project.id)).toContain(
224 ProjectId.make("project-remote-fresh"),
225 );
226 expect(scopes[0]?.projectRefs).toHaveLength(3);
227 });
228
229 it("sorts v2 project scopes by their grouped thread activity", () => {
230 const environmentId = EnvironmentId.make("environment-1");
231 const olderProject = makeProject({
232 environmentId,
233 id: ProjectId.make("project-older"),
234 title: "Older project",
235 });
236 const newerProject = makeProject({
237 environmentId,
238 id: ProjectId.make("project-newer"),
239 title: "Newer project",
240 });
241 const scopes = buildHomeProjectScopes({
242 projects: [newerProject, olderProject],
243 environmentId: null,
244 projectGroupingMode: "separate",
245 });
246
247 expect(
248 sortHomeProjectScopes({
249 scopes,
250 threads: [
251 makeThread({
252 environmentId,
253 id: ThreadId.make("thread-older-project"),
254 projectId: olderProject.id,
255 title: "Most recently active",
256 updatedAt: "2026-06-03T00:00:00.000Z",
257 }),
258 makeThread({
259 environmentId,
260 id: ThreadId.make("thread-newer-project"),
261 projectId: newerProject.id,
262 title: "Less recently active",
263 updatedAt: "2026-06-02T00:00:00.000Z",
264 }),
265 ],
266 pendingTasks: [],
267 projectSortOrder: "updated_at",
268 }).map((scope) => scope.representative.id),
269 ).toEqual([olderProject.id, newerProject.id]);
270 });
271
272 it("sorts invalid project creation timestamps after valid ones", () => {
273 const environmentId = EnvironmentId.make("environment-1");
274 const invalidProject = makeProject({
275 environmentId,
276 id: ProjectId.make("project-invalid"),
277 title: "A invalid timestamp",
278 createdAt: "invalid",
279 });
280 const validProject = makeProject({
281 environmentId,
282 id: ProjectId.make("project-valid"),
283 title: "Z valid timestamp",
284 createdAt: "2026-06-02T00:00:00.000Z",
285 });
286 const scopes = buildHomeProjectScopes({
287 projects: [invalidProject, validProject],
288 environmentId: null,
289 projectGroupingMode: "separate",
290 });
291
292 expect(
293 sortHomeProjectScopes({
294 scopes,
295 threads: [],
296 pendingTasks: [],
297 projectSortOrder: "created_at",
298 }).map((scope) => scope.representative.id),
299 ).toEqual([validProject.id, invalidProject.id]);
300 });
301
302 it("uses the freshest member when a grouped scope has no activity", () => {
303 const localEnvironmentId = EnvironmentId.make("environment-local");
304 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
305 const repositoryIdentity = {
306 canonicalKey: "github.com/pingdotgg/t3code",
307 locator: {
308 source: "git-remote" as const,
309 remoteName: "origin",
310 remoteUrl: "git@github.com:pingdotgg/t3code.git",
311 },
312 };
313 const olderMember = makeProject({
314 environmentId: localEnvironmentId,
315 id: ProjectId.make("project-older-member"),
316 title: "t3code",
317 updatedAt: "2026-06-01T00:00:00.000Z",
318 repositoryIdentity,
319 });
320 const newerMember = makeProject({
321 environmentId: remoteEnvironmentId,
322 id: ProjectId.make("project-newer-member"),
323 title: "t3code",
324 updatedAt: "2026-06-03T00:00:00.000Z",
325 repositoryIdentity,
326 });
327 const otherProject = makeProject({
328 environmentId: localEnvironmentId,
329 id: ProjectId.make("project-other"),
330 title: "other",
331 updatedAt: "2026-06-02T00:00:00.000Z",
332 });
333 const scopes = buildHomeProjectScopes({
334 projects: [olderMember, newerMember, otherProject],
335 environmentId: null,
336 projectGroupingMode: "repository",
337 });
338
339 expect(
340 sortHomeProjectScopes({
341 scopes,
342 threads: [],
343 pendingTasks: [],
344 projectSortOrder: "updated_at",
345 })[0]?.key,
346 ).toBe(scopes.find((scope) => scope.projects.length === 2)?.key);
347 });
348
349 it("does not merge unrelated repositories that share a title", () => {
350 const environmentId = EnvironmentId.make("environment-1");
351 const projects = ["one", "two"].map((name) =>
352 makeProject({
353 environmentId,
354 id: ProjectId.make(`project-${name}`),
355 title: "app",
356 repositoryIdentity: {
357 canonicalKey: `github.com/example/${name}`,
358 locator: {
359 source: "git-remote" as const,
360 remoteName: "origin",
361 remoteUrl: `git@github.com:example/${name}.git`,
362 },
363 },
364 }),
365 );
366
367 expect(
368 buildHomeProjectScopes({
369 projects,
370 environmentId: null,
371 projectGroupingMode: "repository",
372 }),
373 ).toHaveLength(2);
374 });
375
70 it("sorts the newest thread first regardless of snapshot order", () => {376 it("sorts the newest thread first regardless of snapshot order", () => {
71 const environmentId = EnvironmentId.make("environment-1");377 const environmentId = EnvironmentId.make("environment-1");
72 const project = makeProject({378 const project = makeProject({
apps/mobile/src/features/home/homeThreadList.ts+187-25
...@@ -1,14 +1,20 @@...@@ -1,14 +1,20 @@
1import {1import {
2 deriveLogicalProjectKey,2 deriveLogicalProjectKey,
3 derivePhysicalProjectKey,
3 deriveProjectGroupLabel,4 deriveProjectGroupLabel,
4} from "@t3tools/client-runtime/state/project-grouping";5} from "@t3tools/client-runtime/state/project-grouping";
5import type {6import type {
6 EnvironmentProject,7 EnvironmentProject,
7 EnvironmentThreadShell,8 EnvironmentThreadShell,
8} from "@t3tools/client-runtime/state/shell";9} from "@t3tools/client-runtime/state/shell";
9import { getThreadSortTimestamp, sortThreads } from "@t3tools/client-runtime/state/thread-sort";10import {
11 getThreadSortTimestamp,
12 sortThreads,
13 toSortableTimestamp,
14} from "@t3tools/client-runtime/state/thread-sort";
10import type {15import type {
11 EnvironmentId,16 EnvironmentId,
17 ScopedProjectRef,
12 SidebarProjectGroupingMode,18 SidebarProjectGroupingMode,
13 SidebarProjectSortOrder,19 SidebarProjectSortOrder,
14 SidebarThreadSortOrder,20 SidebarThreadSortOrder,
...@@ -22,6 +28,164 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";...@@ -22,6 +28,164 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2228
23export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">;29export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">;
2430
31export interface HomeProjectScope {
32 readonly key: string;
33 readonly title: string;
34 readonly representative: EnvironmentProject;
35 readonly projects: ReadonlyArray<EnvironmentProject>;
36 readonly projectRefs: ReadonlyArray<ScopedProjectRef>;
37}
38
39function getProjectFreshnessTimestamp(project: EnvironmentProject): number {
40 return toSortableTimestamp(project.updatedAt) ?? toSortableTimestamp(project.createdAt) ?? 0;
41}
42
43function getProjectSortTimestamp(
44 project: EnvironmentProject,
45 sortOrder: HomeProjectSortOrder,
46): number {
47 return sortOrder === "created_at"
48 ? (toSortableTimestamp(project.createdAt) ?? Number.NEGATIVE_INFINITY)
49 : (toSortableTimestamp(project.updatedAt) ??
50 toSortableTimestamp(project.createdAt) ??
51 Number.NEGATIVE_INFINITY);
52}
53
54export function buildHomeProjectScopes(input: {
55 readonly projects: ReadonlyArray<EnvironmentProject>;
56 readonly environmentId: EnvironmentId | null;
57 readonly projectGroupingMode: SidebarProjectGroupingMode;
58}): ReadonlyArray<HomeProjectScope> {
59 const projects = input.projects.filter(
60 (project) => input.environmentId === null || project.environmentId === input.environmentId,
61 );
62 const projectsByPhysicalKey = new Map<string, EnvironmentProject[]>();
63 for (const project of projects) {
64 const physicalKey = derivePhysicalProjectKey(project);
65 const existing = projectsByPhysicalKey.get(physicalKey);
66 if (existing) existing.push(project);
67 else projectsByPhysicalKey.set(physicalKey, [project]);
68 }
69
70 const winnersByPhysicalKey = new Map<
71 string,
72 { readonly key: string; readonly project: EnvironmentProject }
73 >();
74 for (const [physicalKey, members] of projectsByPhysicalKey) {
75 const project = members.reduce((winner, candidate) => {
76 const freshnessDelta =
77 getProjectFreshnessTimestamp(candidate) - getProjectFreshnessTimestamp(winner);
78 return freshnessDelta > 0 || (freshnessDelta === 0 && candidate.id > winner.id)
79 ? candidate
80 : winner;
81 });
82 const identitySource = members.find((member) => member.repositoryIdentity !== null) ?? project;
83 winnersByPhysicalKey.set(physicalKey, {
84 key: deriveLogicalProjectKey(identitySource, { groupingMode: input.projectGroupingMode }),
85 project,
86 });
87 }
88
89 const groups = new Map<string, EnvironmentProject[]>();
90 for (const { key, project } of winnersByPhysicalKey.values()) {
91 const existing = groups.get(key);
92 if (existing) existing.push(project);
93 else groups.set(key, [project]);
94 }
95
96 const projectRefsByGroup = new Map<string, ScopedProjectRef[]>();
97 const seenProjectRefs = new Set<string>();
98 for (const project of projects) {
99 const refKey = scopedProjectKey(project.environmentId, project.id);
100 if (seenProjectRefs.has(refKey)) continue;
101 seenProjectRefs.add(refKey);
102
103 const key =
104 winnersByPhysicalKey.get(derivePhysicalProjectKey(project))?.key ??
105 deriveLogicalProjectKey(project, { groupingMode: input.projectGroupingMode });
106 const refs = projectRefsByGroup.get(key);
107 const projectRef = { environmentId: project.environmentId, projectId: project.id };
108 if (refs) refs.push(projectRef);
109 else projectRefsByGroup.set(key, [projectRef]);
110 }
111
112 return Array.from(groups, ([key, projects]) => {
113 const representative = projects[0]!;
114 return {
115 key,
116 title:
117 projects.length > 1
118 ? deriveProjectGroupLabel({ representative, members: projects })
119 : representative.title,
120 representative,
121 projects,
122 projectRefs: projectRefsByGroup.get(key) ?? [],
123 };
124 });
125}
126
127export function sortHomeProjectScopes(input: {
128 readonly scopes: ReadonlyArray<HomeProjectScope>;
129 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
130 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
131 readonly projectSortOrder: HomeProjectSortOrder;
132}): ReadonlyArray<HomeProjectScope> {
133 const scopeKeyByProjectRef = new Map(
134 input.scopes.flatMap((scope) =>
135 scope.projectRefs.map(
136 (projectRef) =>
137 [scopedProjectKey(projectRef.environmentId, projectRef.projectId), scope.key] as const,
138 ),
139 ),
140 );
141 const latestActivityByScope = new Map<string, number>();
142 const recordActivity = (scopeKey: string | undefined, timestamp: number) => {
143 if (!scopeKey || !Number.isFinite(timestamp)) return;
144 latestActivityByScope.set(
145 scopeKey,
146 Math.max(latestActivityByScope.get(scopeKey) ?? Number.NEGATIVE_INFINITY, timestamp),
147 );
148 };
149
150 for (const thread of input.threads) {
151 if (thread.archivedAt !== null) continue;
152 recordActivity(
153 scopeKeyByProjectRef.get(scopedProjectKey(thread.environmentId, thread.projectId)),
154 getThreadSortTimestamp(thread, input.projectSortOrder),
155 );
156 }
157 for (const pendingTask of input.pendingTasks) {
158 recordActivity(
159 scopeKeyByProjectRef.get(
160 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
161 ),
162 Date.parse(pendingTask.message.createdAt),
163 );
164 }
165
166 return Arr.sort(
167 input.scopes,
168 Order.mapInput(
169 Order.Struct({
170 timestamp: Order.flip(Order.Number),
171 title: Order.String,
172 key: Order.String,
173 }),
174 (scope: HomeProjectScope) => ({
175 timestamp:
176 latestActivityByScope.get(scope.key) ??
177 Math.max(
178 ...scope.projects.map((project) =>
179 getProjectSortTimestamp(project, input.projectSortOrder),
180 ),
181 ),
182 title: scope.title,
183 key: scope.key,
184 }),
185 ),
186 );
187}
188
25/**189/**
26 * Default home view only surfaces threads active within this window, to keep the190 * Default home view only surfaces threads active within this window, to keep the
27 * screen compact while keeping recent work visible.191 * screen compact while keeping recent work visible.
...@@ -103,22 +267,18 @@ export function buildHomeThreadGroups(input: {...@@ -103,22 +267,18 @@ export function buildHomeThreadGroups(input: {
103 const groups = new Map<string, MutableHomeThreadGroup>();267 const groups = new Map<string, MutableHomeThreadGroup>();
104 const groupKeyByProjectKey = new Map<string, string>();268 const groupKeyByProjectKey = new Map<string, string>();
105269
106 for (const project of input.projects) {270 for (const scope of buildHomeProjectScopes(input)) {
107 if (input.environmentId !== null && project.environmentId !== input.environmentId) {271 groups.set(scope.key, {
108 continue;272 key: scope.key,
109 }273 projects: [...scope.projects],
110274 pendingTasks: [],
111 const groupKey = deriveLogicalProjectKey(project, {275 threads: [],
112 groupingMode: input.projectGroupingMode,
113 });276 });
114 const physicalKey = scopedProjectKey(project.environmentId, project.id);277 for (const projectRef of scope.projectRefs) {
115 groupKeyByProjectKey.set(physicalKey, groupKey);278 groupKeyByProjectKey.set(
116279 scopedProjectKey(projectRef.environmentId, projectRef.projectId),
117 const existing = groups.get(groupKey);280 scope.key,
118 if (existing) {281 );
119 existing.projects.push(project);
120 } else {
121 groups.set(groupKey, { key: groupKey, projects: [project], pendingTasks: [], threads: [] });
122 }282 }
123 }283 }
124284
...@@ -215,21 +375,23 @@ export function buildHomeThreadGroups(input: {...@@ -215,21 +375,23 @@ export function buildHomeThreadGroups(input: {
215 ? selectRecentThreads(sortedThreads, input.threadSortOrder, now)375 ? selectRecentThreads(sortedThreads, input.threadSortOrder, now)
216 : sortedThreads;376 : sortedThreads;
217377
218 // Sorted newest-first, so the first thread whose project is a group member378 // A stale project id still resolves to the canonical member with the same
219 // marks the machine the user last worked on.379 // environment/path, so quick creation follows the machine with the newest activity.
220 const lastActiveProject = Arr.findFirst(sortedThreads, (thread) =>380 const lastActiveProject = Arr.head(sortedThreads).pipe(
221 group.projects.some(
222 (project) =>
223 project.environmentId === thread.environmentId && project.id === thread.projectId,
224 ),
225 ).pipe(
226 Option.flatMap((thread) =>381 Option.flatMap((thread) =>
227 Arr.findFirst(382 Arr.findFirst(
228 group.projects,383 input.projects,
229 (project) =>384 (project) =>
230 project.environmentId === thread.environmentId && project.id === thread.projectId,385 project.environmentId === thread.environmentId && project.id === thread.projectId,
231 ),386 ),
232 ),387 ),
388 Option.flatMap((threadProject) =>
389 Arr.findFirst(
390 group.projects,
391 (project) =>
392 derivePhysicalProjectKey(project) === derivePhysicalProjectKey(threadProject),
393 ),
394 ),
233 Option.getOrNull,395 Option.getOrNull,
234 );396 );
235397
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+55-45
...@@ -48,7 +48,7 @@ import {...@@ -48,7 +48,7 @@ import {
48 type HomeGroupDisplayState,48 type HomeGroupDisplayState,
49 type HomeListItem,49 type HomeListItem,
50} from "../home/homeListItems";50} from "../home/homeListItems";
51import { buildHomeThreadGroups } from "../home/homeThreadList";51import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThreadList";
52import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";52import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";
53import { usePendingTaskListActions } from "../home/usePendingTaskListActions";53import { usePendingTaskListActions } from "../home/usePendingTaskListActions";
54import { useThreadListActions } from "../home/useThreadListActions";54import { useThreadListActions } from "../home/useThreadListActions";
...@@ -225,28 +225,29 @@ function ThreadNavigationSidebarPane(...@@ -225,28 +225,29 @@ function ThreadNavigationSidebarPane(
225 setThreadSortOrder,225 setThreadSortOrder,
226 } = useHomeListOptions(availableEnvironmentIds);226 } = useHomeListOptions(availableEnvironmentIds);
227 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);227 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
228 const projectScopes = useMemo(
229 () =>
230 buildHomeProjectScopes({
231 projects,
232 environmentId: options.selectedEnvironmentId,
233 projectGroupingMode: options.projectGroupingMode,
234 }),
235 [options.projectGroupingMode, options.selectedEnvironmentId, projects],
236 );
228 const projectFilterOptions = useMemo(237 const projectFilterOptions = useMemo(
229 () =>238 () =>
230 projects239 projectScopes.map((scope) => ({
231 .filter(240 key: scope.key,
232 (project) =>241 label: scope.title,
233 options.selectedEnvironmentId === null ||242 })),
234 project.environmentId === options.selectedEnvironmentId,243 [projectScopes],
235 )
236 .map((project) => ({
237 key: scopedProjectKey(project.environmentId, project.id),
238 label: project.title,
239 })),
240 [options.selectedEnvironmentId, projects],
241 );244 );
242 const selectedProject = useMemo(245 const selectedProjectScope = useMemo(
243 () =>246 () =>
244 selectedProjectKey === null247 selectedProjectKey === null
245 ? null248 ? null
246 : (projects.find(249 : (projectScopes.find((scope) => scope.key === selectedProjectKey) ?? null),
247 (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey,250 [projectScopes, selectedProjectKey],
248 ) ?? null),
249 [projects, selectedProjectKey],
250 );251 );
251 useEffect(() => {252 useEffect(() => {
252 if (253 if (
...@@ -256,31 +257,45 @@ function ThreadNavigationSidebarPane(...@@ -256,31 +257,45 @@ function ThreadNavigationSidebarPane(
256 setSelectedProjectKey(null);257 setSelectedProjectKey(null);
257 }258 }
258 }, [projectFilterOptions, selectedProjectKey]);259 }, [projectFilterOptions, selectedProjectKey]);
260 const selectedProjectRefs = useMemo(
261 () =>
262 selectedProjectScope === null
263 ? null
264 : new Set(
265 selectedProjectScope.projectRefs.map((projectRef) =>
266 scopedProjectKey(projectRef.environmentId, projectRef.projectId),
267 ),
268 ),
269 [selectedProjectScope],
270 );
259 const scopedProjects = useMemo(271 const scopedProjects = useMemo(
260 () => (selectedProject === null ? projects : [selectedProject]),272 () =>
261 [projects, selectedProject],273 selectedProjectRefs === null
274 ? projects
275 : projects.filter((project) =>
276 selectedProjectRefs.has(scopedProjectKey(project.environmentId, project.id)),
277 ),
278 [projects, selectedProjectRefs],
262 );279 );
263 const scopedThreads = useMemo(280 const scopedThreads = useMemo(
264 () =>281 () =>
265 selectedProject === null282 selectedProjectRefs === null
266 ? threads283 ? threads
267 : threads.filter(284 : threads.filter((thread) =>
268 (thread) =>285 selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)),
269 thread.environmentId === selectedProject.environmentId &&
270 thread.projectId === selectedProject.id,
271 ),286 ),
272 [selectedProject, threads],287 [selectedProjectRefs, threads],
273 );288 );
274 const scopedPendingTasks = useMemo(289 const scopedPendingTasks = useMemo(
275 () =>290 () =>
276 selectedProject === null291 selectedProjectRefs === null
277 ? pendingTasks292 ? pendingTasks
278 : pendingTasks.filter(293 : pendingTasks.filter((pendingTask) =>
279 (pendingTask) =>294 selectedProjectRefs.has(
280 pendingTask.message.environmentId === selectedProject.environmentId &&295 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
281 pendingTask.creation.projectId === selectedProject.id,296 ),
282 ),297 ),
283 [pendingTasks, selectedProject],298 [pendingTasks, selectedProjectRefs],
284 );299 );
285 const groups = useMemo(300 const groups = useMemo(
286 () =>301 () =>
...@@ -401,13 +416,7 @@ function ThreadNavigationSidebarPane(...@@ -401,13 +416,7 @@ function ThreadNavigationSidebarPane(
401 return buildThreadListV2Items({416 return buildThreadListV2Items({
402 threads: threads.filter((thread) => thread.archivedAt === null),417 threads: threads.filter((thread) => thread.archivedAt === null),
403 environmentId: options.selectedEnvironmentId,418 environmentId: options.selectedEnvironmentId,
404 projectRef:419 projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,
405 selectedProject === null
406 ? null
407 : {
408 environmentId: selectedProject.environmentId,
409 projectId: selectedProject.id,
410 },
411 searchQuery: props.searchQuery,420 searchQuery: props.searchQuery,
412 changeRequestStateByKey,421 changeRequestStateByKey,
413 settlementEnvironmentIds,422 settlementEnvironmentIds,
...@@ -423,7 +432,7 @@ function ThreadNavigationSidebarPane(...@@ -423,7 +432,7 @@ function ThreadNavigationSidebarPane(
423 settlementEnvironmentIds,432 settlementEnvironmentIds,
424 threadListV2Enabled,433 threadListV2Enabled,
425 threads,434 threads,
426 selectedProject,435 selectedProjectScope,
427 ]);436 ]);
428 const listItems = useMemo<readonly SidebarListItem[]>(() => {437 const listItems = useMemo<readonly SidebarListItem[]>(() => {
429 if (!threadListV2Enabled) return listLayout.items;438 if (!threadListV2Enabled) return listLayout.items;
...@@ -437,9 +446,10 @@ function ThreadNavigationSidebarPane(...@@ -437,9 +446,10 @@ function ThreadNavigationSidebarPane(
437 (pendingTask) =>446 (pendingTask) =>
438 (options.selectedEnvironmentId === null ||447 (options.selectedEnvironmentId === null ||
439 pendingTask.message.environmentId === options.selectedEnvironmentId) &&448 pendingTask.message.environmentId === options.selectedEnvironmentId) &&
440 (selectedProject === null ||449 (selectedProjectRefs === null ||
441 (pendingTask.message.environmentId === selectedProject.environmentId &&450 selectedProjectRefs.has(
442 pendingTask.creation.projectId === selectedProject.id)) &&451 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
452 )) &&
443 (v2SearchQuery.length === 0 ||453 (v2SearchQuery.length === 0 ||
444 pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),454 pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
445 );455 );
...@@ -469,7 +479,7 @@ function ThreadNavigationSidebarPane(...@@ -469,7 +479,7 @@ function ThreadNavigationSidebarPane(
469 options.selectedEnvironmentId,479 options.selectedEnvironmentId,
470 pendingTasks,480 pendingTasks,
471 props.searchQuery,481 props.searchQuery,
472 selectedProject,482 selectedProjectRefs,
473 threadListV2Enabled,483 threadListV2Enabled,
474 threadListV2Layout,484 threadListV2Layout,
475 ]);485 ]);
...@@ -933,8 +943,8 @@ function ThreadNavigationSidebarPane(...@@ -933,8 +943,8 @@ function ThreadNavigationSidebarPane(
933 ? "Loading threads…"943 ? "Loading threads…"
934 : props.searchQuery.trim().length > 0944 : props.searchQuery.trim().length > 0
935 ? "No matching threads"945 ? "No matching threads"
936 : selectedProject !== null946 : selectedProjectScope !== null
937 ? `No threads in ${selectedProject.title}`947 ? `No threads in ${selectedProjectScope.title}`
938 : "No threads yet"}948 : "No threads yet"}
939 </Text>949 </Text>
940 );950 );
apps/mobile/src/features/threads/threadListV2.test.ts+24-1
...@@ -166,13 +166,36 @@ describe("buildThreadListV2Items", () => {...@@ -166,13 +166,36 @@ describe("buildThreadListV2Items", () => {
166 }),166 }),
167 ],167 ],
168 environmentId: null,168 environmentId: null,
169 projectRef: { environmentId, projectId: ProjectId.make("project-1") },169 projectRefs: [{ environmentId, projectId: ProjectId.make("project-1") }],
170 searchQuery: "",170 searchQuery: "",
171 now: NOW,171 now: NOW,
172 });172 });
173173
174 expect(items.map((item) => item.thread.id)).toEqual(["included"]);174 expect(items.map((item) => item.thread.id)).toEqual(["included"]);
175 });175 });
176
177 it("scopes the flat list to every environment member of a logical project", () => {
178 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
179 const { items } = buildThreadListV2Items({
180 threads: [
181 makeThread({ id: ThreadId.make("local"), title: "Local" }),
182 makeThread({
183 environmentId: remoteEnvironmentId,
184 id: ThreadId.make("remote"),
185 title: "Remote",
186 }),
187 ],
188 environmentId: null,
189 projectRefs: [
190 { environmentId, projectId: ProjectId.make("project-1") },
191 { environmentId: remoteEnvironmentId, projectId: ProjectId.make("project-1") },
192 ],
193 searchQuery: "",
194 now: NOW,
195 });
196
197 expect(items.map((item) => item.thread.id)).toEqual(["local", "remote"]);
198 });
176});199});
177200
178describe("buildThreadListV2Items settled paging", () => {201describe("buildThreadListV2Items settled paging", () => {
apps/mobile/src/features/threads/threadListV2.ts+6-7
...@@ -95,10 +95,10 @@ export interface ThreadListV2Layout {...@@ -95,10 +95,10 @@ export interface ThreadListV2Layout {
95export function buildThreadListV2Items(input: {95export function buildThreadListV2Items(input: {
96 readonly threads: ReadonlyArray<EnvironmentThreadShell>;96 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
97 readonly environmentId: EnvironmentId | null;97 readonly environmentId: EnvironmentId | null;
98 readonly projectRef?: {98 readonly projectRefs?: ReadonlyArray<{
99 readonly environmentId: EnvironmentId;99 readonly environmentId: EnvironmentId;
100 readonly projectId: ProjectId;100 readonly projectId: ProjectId;
101 } | null;101 }> | null;
102 readonly searchQuery: string;102 readonly searchQuery: string;
103 /** Per-row PR state reported up by visible rows ("env:threadId" keys). */103 /** Per-row PR state reported up by visible rows ("env:threadId" keys). */
104 readonly changeRequestStateByKey?: ReadonlyMap<string, "open" | "closed" | "merged">;104 readonly changeRequestStateByKey?: ReadonlyMap<string, "open" | "closed" | "merged">;
...@@ -115,6 +115,9 @@ export function buildThreadListV2Items(input: {...@@ -115,6 +115,9 @@ export function buildThreadListV2Items(input: {
115 const now = input.now ?? new Date().toISOString();115 const now = input.now ?? new Date().toISOString();
116 const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;116 const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
117 const query = input.searchQuery.trim().toLocaleLowerCase();117 const query = input.searchQuery.trim().toLocaleLowerCase();
118 const projectKeys = input.projectRefs
119 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
120 : null;
118121
119 const active: EnvironmentThreadShell[] = [];122 const active: EnvironmentThreadShell[] = [];
120 const settled: EnvironmentThreadShell[] = [];123 const settled: EnvironmentThreadShell[] = [];
...@@ -122,11 +125,7 @@ export function buildThreadListV2Items(input: {...@@ -122,11 +125,7 @@ export function buildThreadListV2Items(input: {
122 // Callers pass live (unarchived) shells; settled threads are among them125 // Callers pass live (unarchived) shells; settled threads are among them
123 // and partition into the tail via effectiveSettled.126 // and partition into the tail via effectiveSettled.
124 if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue;127 if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue;
125 if (128 if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) {
126 input.projectRef != null &&
127 (thread.environmentId !== input.projectRef.environmentId ||
128 thread.projectId !== input.projectRef.projectId)
129 ) {
130 continue;129 continue;
131 }130 }
132 if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue;131 if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue;