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 {
1515import { useAtomSet, useAtomValue } from "@effect/atom-react";
1616import { AsyncResult } from "effect/unstable/reactivity";
1717import { 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";
1919import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
2020import { useSafeAreaInsets } from "react-native-safe-area-context";
2121import { useThemeColor } from "../../lib/useThemeColor";
2222
2323import { AppText as Text } from "../../components/AppText";
2424import { EmptyState } from "../../components/EmptyState";
25import { ProjectFavicon } from "../../components/ProjectFavicon";
2625import type { WorkspaceState } from "../../state/workspaceModel";
2726import type { SavedRemoteConnection } from "../../lib/connection";
28import { cn } from "../../lib/cn";
2927import { scopedProjectKey } from "../../lib/scopedEntities";
3028import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
3129import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
......@@ -54,7 +52,12 @@ import {
5452 type HomeGroupDisplayState,
5553 type HomeListItem,
5654} from "./homeListItems";
57import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList";
55import {
56 buildHomeProjectScopes,
57 buildHomeThreadGroups,
58 sortHomeProjectScopes,
59 type HomeProjectSortOrder,
60} from "./homeThreadList";
5861import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";
5962import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus";
6063import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status";
......@@ -334,23 +337,53 @@ export function HomeScreen(props: HomeScreenProps) {
334337 }, [props.projects]);
335338
336339 const v2ProjectScopeKey = props.selectedProjectKey;
337 const setV2ProjectScopeKey = props.onProjectChange;
338340 const v2ScopeProjects = useMemo(
339341 () =>
340 props.selectedEnvironmentId === null
341 ? props.projects
342 : props.projects.filter((project) => project.environmentId === props.selectedEnvironmentId),
343 [props.projects, props.selectedEnvironmentId],
342 sortHomeProjectScopes({
343 scopes: buildHomeProjectScopes({
344 projects: props.projects,
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 ],
344360 );
345 const v2ScopedProject = useMemo(
361 const v2ScopedProjectGroup = useMemo(
346362 () =>
347363 v2ProjectScopeKey === null
348364 ? null
349365 : (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 ),
351373 ) ?? null),
352374 [v2ProjectScopeKey, v2ScopeProjects],
353375 );
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 );
354387 // Thread List v2 (beta): one flat list in creation order, no grouping.
355388 // Settled threads collapse into a recency tail below the card block.
356389 // Settled threads stay in the live shell stream (settled ≠ archived), so
......@@ -431,13 +464,7 @@ export function HomeScreen(props: HomeScreenProps) {
431464 return buildThreadListV2Items({
432465 threads: props.threads.filter((thread) => thread.archivedAt === null),
433466 environmentId: props.selectedEnvironmentId,
434 projectRef:
435 v2ScopedProject === null
436 ? null
437 : {
438 environmentId: v2ScopedProject.environmentId,
439 projectId: v2ScopedProject.id,
440 },
467 projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
441468 searchQuery: props.searchQuery,
442469 changeRequestStateByKey,
443470 settlementEnvironmentIds,
......@@ -453,7 +480,7 @@ export function HomeScreen(props: HomeScreenProps) {
453480 props.selectedEnvironmentId,
454481 props.threads,
455482 threadListV2Enabled,
456 v2ScopedProject,
483 v2ScopedProjectGroup,
457484 ]);
458485 const threadListV2Items = threadListV2Layout.items;
459486
......@@ -699,9 +726,10 @@ export function HomeScreen(props: HomeScreenProps) {
699726 (pendingTask) =>
700727 (props.selectedEnvironmentId === null ||
701728 pendingTask.message.environmentId === props.selectedEnvironmentId) &&
702 (v2ScopedProject === null ||
703 (pendingTask.message.environmentId === v2ScopedProject.environmentId &&
704 pendingTask.creation.projectId === v2ScopedProject.id)) &&
729 (v2ScopedProjectKeys === null ||
730 v2ScopedProjectKeys.has(
731 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
732 )) &&
705733 (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
706734 );
707735 // Project scoping lives in the header filter menu (no inline chip row on
......@@ -750,9 +778,9 @@ export function HomeScreen(props: HomeScreenProps) {
750778 const v2ListEmpty =
751779 v2PendingTasks.length > 0 ? null : hasSearchQuery ? (
752780 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
753 ) : v2ScopedProject !== null ? (
781 ) : v2ScopedProjectGroup !== null ? (
754782 <EmptyState
755 title={`No threads in ${v2ScopedProject.title}`}
783 title={`No threads in ${v2ScopedProjectGroup.title}`}
756784 detail="Choose another project or create a new task."
757785 />
758786 ) : (
apps/mobile/src/features/home/homeThreadList.test.ts+307-1
......@@ -5,7 +5,11 @@ import type {
55import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
66import { describe, expect, it } from "vite-plus/test";
77
8import { buildHomeThreadGroups } from "./homeThreadList";
8import {
9 buildHomeProjectScopes,
10 buildHomeThreadGroups,
11 sortHomeProjectScopes,
12} from "./homeThreadList";
913
1014function makeProject(
1115 input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">,
......@@ -67,6 +71,308 @@ function buildGroups(
6771}
6872
6973describe("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
70376 it("sorts the newest thread first regardless of snapshot order", () => {
71377 const environmentId = EnvironmentId.make("environment-1");
72378 const project = makeProject({
apps/mobile/src/features/home/homeThreadList.ts+187-25
......@@ -1,14 +1,20 @@
11import {
22 deriveLogicalProjectKey,
3 derivePhysicalProjectKey,
34 deriveProjectGroupLabel,
45} from "@t3tools/client-runtime/state/project-grouping";
56import type {
67 EnvironmentProject,
78 EnvironmentThreadShell,
89} 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";
1015import type {
1116 EnvironmentId,
17 ScopedProjectRef,
1218 SidebarProjectGroupingMode,
1319 SidebarProjectSortOrder,
1420 SidebarThreadSortOrder,
......@@ -22,6 +28,164 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2228
2329export 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
25189/**
26190 * Default home view only surfaces threads active within this window, to keep the
27191 * screen compact while keeping recent work visible.
......@@ -103,22 +267,18 @@ export function buildHomeThreadGroups(input: {
103267 const groups = new Map<string, MutableHomeThreadGroup>();
104268 const groupKeyByProjectKey = new Map<string, string>();
105269
106 for (const project of input.projects) {
107 if (input.environmentId !== null && project.environmentId !== input.environmentId) {
108 continue;
109 }
110
111 const groupKey = deriveLogicalProjectKey(project, {
112 groupingMode: input.projectGroupingMode,
270 for (const scope of buildHomeProjectScopes(input)) {
271 groups.set(scope.key, {
272 key: scope.key,
273 projects: [...scope.projects],
274 pendingTasks: [],
275 threads: [],
113276 });
114 const physicalKey = scopedProjectKey(project.environmentId, project.id);
115 groupKeyByProjectKey.set(physicalKey, groupKey);
116
117 const existing = groups.get(groupKey);
118 if (existing) {
119 existing.projects.push(project);
120 } else {
121 groups.set(groupKey, { key: groupKey, projects: [project], pendingTasks: [], threads: [] });
277 for (const projectRef of scope.projectRefs) {
278 groupKeyByProjectKey.set(
279 scopedProjectKey(projectRef.environmentId, projectRef.projectId),
280 scope.key,
281 );
122282 }
123283 }
124284
......@@ -215,21 +375,23 @@ export function buildHomeThreadGroups(input: {
215375 ? selectRecentThreads(sortedThreads, input.threadSortOrder, now)
216376 : sortedThreads;
217377
218 // Sorted newest-first, so the first thread whose project is a group member
219 // marks the machine the user last worked on.
220 const lastActiveProject = Arr.findFirst(sortedThreads, (thread) =>
221 group.projects.some(
222 (project) =>
223 project.environmentId === thread.environmentId && project.id === thread.projectId,
224 ),
225 ).pipe(
378 // A stale project id still resolves to the canonical member with the same
379 // environment/path, so quick creation follows the machine with the newest activity.
380 const lastActiveProject = Arr.head(sortedThreads).pipe(
226381 Option.flatMap((thread) =>
227382 Arr.findFirst(
228 group.projects,
383 input.projects,
229384 (project) =>
230385 project.environmentId === thread.environmentId && project.id === thread.projectId,
231386 ),
232387 ),
388 Option.flatMap((threadProject) =>
389 Arr.findFirst(
390 group.projects,
391 (project) =>
392 derivePhysicalProjectKey(project) === derivePhysicalProjectKey(threadProject),
393 ),
394 ),
233395 Option.getOrNull,
234396 );
235397
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+55-45
......@@ -48,7 +48,7 @@ import {
4848 type HomeGroupDisplayState,
4949 type HomeListItem,
5050} from "../home/homeListItems";
51import { buildHomeThreadGroups } from "../home/homeThreadList";
51import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThreadList";
5252import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";
5353import { usePendingTaskListActions } from "../home/usePendingTaskListActions";
5454import { useThreadListActions } from "../home/useThreadListActions";
......@@ -225,28 +225,29 @@ function ThreadNavigationSidebarPane(
225225 setThreadSortOrder,
226226 } = useHomeListOptions(availableEnvironmentIds);
227227 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 );
228237 const projectFilterOptions = useMemo(
229238 () =>
230 projects
231 .filter(
232 (project) =>
233 options.selectedEnvironmentId === null ||
234 project.environmentId === options.selectedEnvironmentId,
235 )
236 .map((project) => ({
237 key: scopedProjectKey(project.environmentId, project.id),
238 label: project.title,
239 })),
240 [options.selectedEnvironmentId, projects],
239 projectScopes.map((scope) => ({
240 key: scope.key,
241 label: scope.title,
242 })),
243 [projectScopes],
241244 );
242 const selectedProject = useMemo(
245 const selectedProjectScope = useMemo(
243246 () =>
244247 selectedProjectKey === null
245248 ? null
246 : (projects.find(
247 (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey,
248 ) ?? null),
249 [projects, selectedProjectKey],
249 : (projectScopes.find((scope) => scope.key === selectedProjectKey) ?? null),
250 [projectScopes, selectedProjectKey],
250251 );
251252 useEffect(() => {
252253 if (
......@@ -256,31 +257,45 @@ function ThreadNavigationSidebarPane(
256257 setSelectedProjectKey(null);
257258 }
258259 }, [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 );
259271 const scopedProjects = useMemo(
260 () => (selectedProject === null ? projects : [selectedProject]),
261 [projects, selectedProject],
272 () =>
273 selectedProjectRefs === null
274 ? projects
275 : projects.filter((project) =>
276 selectedProjectRefs.has(scopedProjectKey(project.environmentId, project.id)),
277 ),
278 [projects, selectedProjectRefs],
262279 );
263280 const scopedThreads = useMemo(
264281 () =>
265 selectedProject === null
282 selectedProjectRefs === null
266283 ? threads
267 : threads.filter(
268 (thread) =>
269 thread.environmentId === selectedProject.environmentId &&
270 thread.projectId === selectedProject.id,
284 : threads.filter((thread) =>
285 selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)),
271286 ),
272 [selectedProject, threads],
287 [selectedProjectRefs, threads],
273288 );
274289 const scopedPendingTasks = useMemo(
275290 () =>
276 selectedProject === null
291 selectedProjectRefs === null
277292 ? pendingTasks
278 : pendingTasks.filter(
279 (pendingTask) =>
280 pendingTask.message.environmentId === selectedProject.environmentId &&
281 pendingTask.creation.projectId === selectedProject.id,
293 : pendingTasks.filter((pendingTask) =>
294 selectedProjectRefs.has(
295 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
296 ),
282297 ),
283 [pendingTasks, selectedProject],
298 [pendingTasks, selectedProjectRefs],
284299 );
285300 const groups = useMemo(
286301 () =>
......@@ -401,13 +416,7 @@ function ThreadNavigationSidebarPane(
401416 return buildThreadListV2Items({
402417 threads: threads.filter((thread) => thread.archivedAt === null),
403418 environmentId: options.selectedEnvironmentId,
404 projectRef:
405 selectedProject === null
406 ? null
407 : {
408 environmentId: selectedProject.environmentId,
409 projectId: selectedProject.id,
410 },
419 projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,
411420 searchQuery: props.searchQuery,
412421 changeRequestStateByKey,
413422 settlementEnvironmentIds,
......@@ -423,7 +432,7 @@ function ThreadNavigationSidebarPane(
423432 settlementEnvironmentIds,
424433 threadListV2Enabled,
425434 threads,
426 selectedProject,
435 selectedProjectScope,
427436 ]);
428437 const listItems = useMemo<readonly SidebarListItem[]>(() => {
429438 if (!threadListV2Enabled) return listLayout.items;
......@@ -437,9 +446,10 @@ function ThreadNavigationSidebarPane(
437446 (pendingTask) =>
438447 (options.selectedEnvironmentId === null ||
439448 pendingTask.message.environmentId === options.selectedEnvironmentId) &&
440 (selectedProject === null ||
441 (pendingTask.message.environmentId === selectedProject.environmentId &&
442 pendingTask.creation.projectId === selectedProject.id)) &&
449 (selectedProjectRefs === null ||
450 selectedProjectRefs.has(
451 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
452 )) &&
443453 (v2SearchQuery.length === 0 ||
444454 pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
445455 );
......@@ -469,7 +479,7 @@ function ThreadNavigationSidebarPane(
469479 options.selectedEnvironmentId,
470480 pendingTasks,
471481 props.searchQuery,
472 selectedProject,
482 selectedProjectRefs,
473483 threadListV2Enabled,
474484 threadListV2Layout,
475485 ]);
......@@ -933,8 +943,8 @@ function ThreadNavigationSidebarPane(
933943 ? "Loading threads…"
934944 : props.searchQuery.trim().length > 0
935945 ? "No matching threads"
936 : selectedProject !== null
937 ? `No threads in ${selectedProject.title}`
946 : selectedProjectScope !== null
947 ? `No threads in ${selectedProjectScope.title}`
938948 : "No threads yet"}
939949 </Text>
940950 );
apps/mobile/src/features/threads/threadListV2.test.ts+24-1
......@@ -166,13 +166,36 @@ describe("buildThreadListV2Items", () => {
166166 }),
167167 ],
168168 environmentId: null,
169 projectRef: { environmentId, projectId: ProjectId.make("project-1") },
169 projectRefs: [{ environmentId, projectId: ProjectId.make("project-1") }],
170170 searchQuery: "",
171171 now: NOW,
172172 });
173173
174174 expect(items.map((item) => item.thread.id)).toEqual(["included"]);
175175 });
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 });
176199});
177200
178201describe("buildThreadListV2Items settled paging", () => {
apps/mobile/src/features/threads/threadListV2.ts+6-7
......@@ -95,10 +95,10 @@ export interface ThreadListV2Layout {
9595export function buildThreadListV2Items(input: {
9696 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
9797 readonly environmentId: EnvironmentId | null;
98 readonly projectRef?: {
98 readonly projectRefs?: ReadonlyArray<{
9999 readonly environmentId: EnvironmentId;
100100 readonly projectId: ProjectId;
101 } | null;
101 }> | null;
102102 readonly searchQuery: string;
103103 /** Per-row PR state reported up by visible rows ("env:threadId" keys). */
104104 readonly changeRequestStateByKey?: ReadonlyMap<string, "open" | "closed" | "merged">;
......@@ -115,6 +115,9 @@ export function buildThreadListV2Items(input: {
115115 const now = input.now ?? new Date().toISOString();
116116 const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
117117 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
119122 const active: EnvironmentThreadShell[] = [];
120123 const settled: EnvironmentThreadShell[] = [];
......@@ -122,11 +125,7 @@ export function buildThreadListV2Items(input: {
122125 // Callers pass live (unarchived) shells; settled threads are among them
123126 // and partition into the tail via effectiveSettled.
124127 if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue;
125 if (
126 input.projectRef != null &&
127 (thread.environmentId !== input.projectRef.environmentId ||
128 thread.projectId !== input.projectRef.projectId)
129 ) {
128 if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) {
130129 continue;
131130 }
132131 if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue;