History

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

Julius Marminge committed 3 months ago

Commit 0542abc1 parent 76094956 files · +631 −103Browse files

Unified · Split

6 files
  1. apps/mobile/src/features/home/HomeScreen.tsx +52 −24
  2. apps/mobile/src/features/home/homeThreadList.test.ts +307 −1
  3. apps/mobile/src/features/home/homeThreadList.ts +187 −25
  4. apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +55 −45
  5. apps/mobile/src/features/threads/threadListV2.test.ts +24 −1
  6. apps/mobile/src/features/threads/threadListV2.ts +6 −7
apps/mobile/src/features/home/HomeScreen.tsx+52−24
@@ -15,17 +15,15 @@ import type { · 14 unmodified lines
1515 import { useAtomSet, useAtomValue } from "@effect/atom-react";
1616 import { AsyncResult } from "effect/unstable/reactivity";
1717 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
18−import { ActivityIndicator, FlatList, Platform, Pressable, ScrollView, View } from "react-native";
18+import { ActivityIndicator, FlatList, Platform, Pressable, View } from "react-native";
1919 import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
2020 import { useSafeAreaInsets } from "react-native-safe-area-context";
2121 import { useThemeColor } from "../../lib/useThemeColor";
2222
2323 import { AppText as Text } from "../../components/AppText";
2424 import { EmptyState } from "../../components/EmptyState";
25−import { ProjectFavicon } from "../../components/ProjectFavicon";
2625 import type { WorkspaceState } from "../../state/workspaceModel";
2726 import type { SavedRemoteConnection } from "../../lib/connection";
28−import { cn } from "../../lib/cn";
2927 import { scopedProjectKey } from "../../lib/scopedEntities";
3028 import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
3129 import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
@@ -54,7 +52,12 @@ import { · 22 unmodified lines
5452 type HomeGroupDisplayState,
5553 type HomeListItem,
5654 } from "./homeListItems";
57−import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList";
55+import {
56+ buildHomeProjectScopes,
57+ buildHomeThreadGroups,
58+ sortHomeProjectScopes,
59+ type HomeProjectSortOrder,
60+} from "./homeThreadList";
5861 import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";
5962 import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus";
6063 import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status";
@@ -334,23 +337,53 @@ export function HomeScreen(props: HomeScreenProps) { · 273 unmodified lines
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) { · 74 unmodified lines
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) { · 9 unmodified lines
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) { · 239 unmodified lines
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) { · 42 unmodified lines
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 { · 4 unmodified lines
55 import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
66 import { describe, expect, it } from "vite-plus/test";
77
8−import { buildHomeThreadGroups } from "./homeThreadList";
8+import {
9+ buildHomeProjectScopes,
10+ buildHomeThreadGroups,
11+ sortHomeProjectScopes,
12+} from "./homeThreadList";
913
1014 function makeProject(
1115 input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">,
@@ -67,6 +71,308 @@ function buildGroups( · 55 unmodified lines
6771 }
6872
6973 describe("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 @@
11 import {
22 deriveLogicalProjectKey,
3+ derivePhysicalProjectKey,
34 deriveProjectGroupLabel,
45 } from "@t3tools/client-runtime/state/project-grouping";
56 import type {
67 EnvironmentProject,
78 EnvironmentThreadShell,
89 } from "@t3tools/client-runtime/state/shell";
9−import { getThreadSortTimestamp, sortThreads } from "@t3tools/client-runtime/state/thread-sort";
10+import {
11+ getThreadSortTimestamp,
12+ sortThreads,
13+ toSortableTimestamp,
14+} from "@t3tools/client-runtime/state/thread-sort";
1015 import type {
1116 EnvironmentId,
17+ ScopedProjectRef,
1218 SidebarProjectGroupingMode,
1319 SidebarProjectSortOrder,
1420 SidebarThreadSortOrder,
@@ -22,6 +28,164 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; · 7 unmodified lines
2228
2329 export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">;
2430
31+export 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+
39+function getProjectFreshnessTimestamp(project: EnvironmentProject): number {
40+ return toSortableTimestamp(project.updatedAt) ?? toSortableTimestamp(project.createdAt) ?? 0;
41+}
42+
43+function 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+
54+export 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+
127+export 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: { · 75 unmodified lines
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: { · 90 unmodified lines
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 { · 47 unmodified lines
4848 type HomeGroupDisplayState,
4949 type HomeListItem,
5050 } from "../home/homeListItems";
51−import { buildHomeThreadGroups } from "../home/homeThreadList";
51+import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThreadList";
5252 import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";
5353 import { usePendingTaskListActions } from "../home/usePendingTaskListActions";
5454 import { useThreadListActions } from "../home/useThreadListActions";
@@ -225,28 +225,29 @@ function ThreadNavigationSidebarPane( · 170 unmodified lines
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( · 3 unmodified lines
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( · 114 unmodified lines
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( · 9 unmodified lines
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( · 7 unmodified lines
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( · 23 unmodified lines
469479 options.selectedEnvironmentId,
470480 pendingTasks,
471481 props.searchQuery,
472− selectedProject,
482+ selectedProjectRefs,
473483 threadListV2Enabled,
474484 threadListV2Layout,
475485 ]);
@@ -933,8 +943,8 @@ function ThreadNavigationSidebarPane( · 457 unmodified lines
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", () => { · 165 unmodified lines
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
178201 describe("buildThreadListV2Items settled paging", () => {
apps/mobile/src/features/threads/threadListV2.ts+6−7
@@ -95,10 +95,10 @@ export interface ThreadListV2Layout { · 94 unmodified lines
9595 export 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: { · 10 unmodified lines
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: { · 1 unmodified line
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;