diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index 41180c48643..86d6eeff051 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -15,17 +15,15 @@ import type {
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { ActivityIndicator, FlatList, Platform, Pressable, ScrollView, View } from "react-native";
+import { ActivityIndicator, FlatList, Platform, Pressable, View } from "react-native";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";
import { AppText as Text } from "../../components/AppText";
import { EmptyState } from "../../components/EmptyState";
-import { ProjectFavicon } from "../../components/ProjectFavicon";
import type { WorkspaceState } from "../../state/workspaceModel";
import type { SavedRemoteConnection } from "../../lib/connection";
-import { cn } from "../../lib/cn";
import { scopedProjectKey } from "../../lib/scopedEntities";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
@@ -54,7 +52,12 @@ import {
type HomeGroupDisplayState,
type HomeListItem,
} from "./homeListItems";
-import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList";
+import {
+ buildHomeProjectScopes,
+ buildHomeThreadGroups,
+ sortHomeProjectScopes,
+ type HomeProjectSortOrder,
+} from "./homeThreadList";
import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";
import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus";
import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status";
@@ -334,23 +337,53 @@ export function HomeScreen(props: HomeScreenProps) {
}, [props.projects]);
const v2ProjectScopeKey = props.selectedProjectKey;
- const setV2ProjectScopeKey = props.onProjectChange;
const v2ScopeProjects = useMemo(
() =>
- props.selectedEnvironmentId === null
- ? props.projects
- : props.projects.filter((project) => project.environmentId === props.selectedEnvironmentId),
- [props.projects, props.selectedEnvironmentId],
+ sortHomeProjectScopes({
+ scopes: buildHomeProjectScopes({
+ projects: props.projects,
+ environmentId: props.selectedEnvironmentId,
+ projectGroupingMode: props.projectGroupingMode,
+ }),
+ threads: props.threads,
+ pendingTasks: props.pendingTasks,
+ projectSortOrder: props.projectSortOrder,
+ }),
+ [
+ props.pendingTasks,
+ props.projectGroupingMode,
+ props.projects,
+ props.projectSortOrder,
+ props.selectedEnvironmentId,
+ props.threads,
+ ],
);
- const v2ScopedProject = useMemo(
+ const v2ScopedProjectGroup = useMemo(
() =>
v2ProjectScopeKey === null
? null
: (v2ScopeProjects.find(
- (project) => scopedProjectKey(project.environmentId, project.id) === v2ProjectScopeKey,
+ (scope) =>
+ scope.key === v2ProjectScopeKey ||
+ scope.projectRefs.some(
+ (projectRef) =>
+ scopedProjectKey(projectRef.environmentId, projectRef.projectId) ===
+ v2ProjectScopeKey,
+ ),
) ?? null),
[v2ProjectScopeKey, v2ScopeProjects],
);
+ const v2ScopedProjectKeys = useMemo(
+ () =>
+ v2ScopedProjectGroup === null
+ ? null
+ : new Set(
+ v2ScopedProjectGroup.projectRefs.map((projectRef) =>
+ scopedProjectKey(projectRef.environmentId, projectRef.projectId),
+ ),
+ ),
+ [v2ScopedProjectGroup],
+ );
// Thread List v2 (beta): one flat list in creation order, no grouping.
// Settled threads collapse into a recency tail below the card block.
// Settled threads stay in the live shell stream (settled ≠archived), so
@@ -431,13 +464,7 @@ export function HomeScreen(props: HomeScreenProps) {
return buildThreadListV2Items({
threads: props.threads.filter((thread) => thread.archivedAt === null),
environmentId: props.selectedEnvironmentId,
- projectRef:
- v2ScopedProject === null
- ? null
- : {
- environmentId: v2ScopedProject.environmentId,
- projectId: v2ScopedProject.id,
- },
+ projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
searchQuery: props.searchQuery,
changeRequestStateByKey,
settlementEnvironmentIds,
@@ -453,7 +480,7 @@ export function HomeScreen(props: HomeScreenProps) {
props.selectedEnvironmentId,
props.threads,
threadListV2Enabled,
- v2ScopedProject,
+ v2ScopedProjectGroup,
]);
const threadListV2Items = threadListV2Layout.items;
@@ -699,9 +726,10 @@ export function HomeScreen(props: HomeScreenProps) {
(pendingTask) =>
(props.selectedEnvironmentId === null ||
pendingTask.message.environmentId === props.selectedEnvironmentId) &&
- (v2ScopedProject === null ||
- (pendingTask.message.environmentId === v2ScopedProject.environmentId &&
- pendingTask.creation.projectId === v2ScopedProject.id)) &&
+ (v2ScopedProjectKeys === null ||
+ v2ScopedProjectKeys.has(
+ scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
+ )) &&
(v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
);
// Project scoping lives in the header filter menu (no inline chip row on
@@ -750,9 +778,9 @@ export function HomeScreen(props: HomeScreenProps) {
const v2ListEmpty =
v2PendingTasks.length > 0 ? null : hasSearchQuery ? (
- ) : v2ScopedProject !== null ? (
+ ) : v2ScopedProjectGroup !== null ? (
) : (
diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts
index 46d1173b0dd..ffddbdcc86b 100644
--- a/apps/mobile/src/features/home/homeThreadList.test.ts
+++ b/apps/mobile/src/features/home/homeThreadList.test.ts
@@ -5,7 +5,11 @@ import type {
import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
-import { buildHomeThreadGroups } from "./homeThreadList";
+import {
+ buildHomeProjectScopes,
+ buildHomeThreadGroups,
+ sortHomeProjectScopes,
+} from "./homeThreadList";
function makeProject(
input: Partial & Pick,
@@ -67,6 +71,308 @@ function buildGroups(
}
describe("buildHomeThreadGroups", () => {
+ it("builds one v2 scope for the same repository across environments", () => {
+ const localEnvironmentId = EnvironmentId.make("environment-local");
+ const remoteEnvironmentId = EnvironmentId.make("environment-remote");
+ const repositoryIdentity = {
+ canonicalKey: "github.com/pingdotgg/t3code",
+ locator: {
+ source: "git-remote" as const,
+ remoteName: "origin",
+ remoteUrl: "git@github.com:pingdotgg/t3code.git",
+ },
+ };
+ const projects = [
+ makeProject({
+ environmentId: localEnvironmentId,
+ id: ProjectId.make("project-local"),
+ title: "t3code",
+ repositoryIdentity,
+ }),
+ makeProject({
+ environmentId: remoteEnvironmentId,
+ id: ProjectId.make("project-remote"),
+ title: "t3code",
+ repositoryIdentity,
+ }),
+ ];
+
+ const scopes = buildHomeProjectScopes({
+ projects,
+ environmentId: null,
+ projectGroupingMode: "repository",
+ });
+
+ expect(scopes).toHaveLength(1);
+ expect(scopes[0]?.projects).toEqual(projects);
+ expect(scopes[0]?.projectRefs).toEqual(
+ projects.map((project) => ({
+ environmentId: project.environmentId,
+ projectId: project.id,
+ })),
+ );
+ });
+
+ it("routes stale duplicate project refs through the canonical repository group", () => {
+ const localEnvironmentId = EnvironmentId.make("environment-local");
+ const remoteEnvironmentId = EnvironmentId.make("environment-remote");
+ const repositoryIdentity = {
+ canonicalKey: "github.com/pingdotgg/t3code",
+ locator: {
+ source: "git-remote" as const,
+ remoteName: "origin",
+ remoteUrl: "git@github.com:pingdotgg/t3code.git",
+ },
+ };
+ const local = makeProject({
+ id: ProjectId.make("project-local"),
+ environmentId: localEnvironmentId,
+ title: "t3code",
+ workspaceRoot: "/workspaces/t3code",
+ repositoryIdentity,
+ });
+ const stale = makeProject({
+ environmentId: remoteEnvironmentId,
+ id: ProjectId.make("project-stale"),
+ title: "t3code",
+ workspaceRoot: "/remote/t3code",
+ updatedAt: "2026-06-01T00:00:00.000Z",
+ });
+ const canonicalRemote = makeProject({
+ environmentId: remoteEnvironmentId,
+ id: ProjectId.make("project-canonical-remote"),
+ title: "t3code",
+ workspaceRoot: "/remote/t3code/",
+ repositoryIdentity,
+ updatedAt: "2026-06-02T00:00:00.000Z",
+ });
+ const projects = [local, stale, canonicalRemote];
+ const staleThread = makeThread({
+ environmentId: remoteEnvironmentId,
+ id: ThreadId.make("thread-stale-project-ref"),
+ projectId: stale.id,
+ title: "Still visible",
+ updatedAt: "2026-06-03T00:00:00.000Z",
+ });
+
+ const scopes = buildHomeProjectScopes({
+ projects,
+ environmentId: null,
+ projectGroupingMode: "repository",
+ });
+ const groups = buildGroups(projects, [staleThread]);
+
+ expect(scopes).toHaveLength(1);
+ expect(scopes[0]?.projects.map((project) => project.id)).toEqual([
+ local.id,
+ canonicalRemote.id,
+ ]);
+ expect(scopes[0]?.projectRefs.map((projectRef) => projectRef.projectId)).toEqual([
+ local.id,
+ stale.id,
+ canonicalRemote.id,
+ ]);
+ expect(groups).toHaveLength(1);
+ expect(groups[0]?.threads.map((thread) => thread.id)).toEqual([staleThread.id]);
+ expect(groups[0]?.newThreadTarget?.id).toBe(canonicalRemote.id);
+ });
+
+ it("keeps repository identity from an older duplicate when the freshness winner lacks it", () => {
+ const localEnvironmentId = EnvironmentId.make("environment-local");
+ const remoteEnvironmentId = EnvironmentId.make("environment-remote");
+ const repositoryIdentity = {
+ canonicalKey: "github.com/pingdotgg/t3code",
+ locator: {
+ source: "git-remote" as const,
+ remoteName: "origin",
+ remoteUrl: "git@github.com:pingdotgg/t3code.git",
+ },
+ };
+ const projects = [
+ makeProject({
+ environmentId: localEnvironmentId,
+ id: ProjectId.make("project-local"),
+ title: "t3code",
+ repositoryIdentity,
+ }),
+ makeProject({
+ environmentId: remoteEnvironmentId,
+ id: ProjectId.make("project-remote-with-identity"),
+ title: "t3code",
+ workspaceRoot: "/remote/t3code",
+ repositoryIdentity,
+ updatedAt: "2026-06-01T00:00:00.000Z",
+ }),
+ makeProject({
+ environmentId: remoteEnvironmentId,
+ id: ProjectId.make("project-remote-fresh"),
+ title: "t3code",
+ workspaceRoot: "/remote/t3code/",
+ updatedAt: "2026-06-02T00:00:00.000Z",
+ }),
+ ];
+
+ const scopes = buildHomeProjectScopes({
+ projects,
+ environmentId: null,
+ projectGroupingMode: "repository",
+ });
+
+ expect(scopes).toHaveLength(1);
+ expect(scopes[0]?.representative.id).toBe(ProjectId.make("project-local"));
+ expect(scopes[0]?.projects.map((project) => project.id)).toContain(
+ ProjectId.make("project-remote-fresh"),
+ );
+ expect(scopes[0]?.projectRefs).toHaveLength(3);
+ });
+
+ it("sorts v2 project scopes by their grouped thread activity", () => {
+ const environmentId = EnvironmentId.make("environment-1");
+ const olderProject = makeProject({
+ environmentId,
+ id: ProjectId.make("project-older"),
+ title: "Older project",
+ });
+ const newerProject = makeProject({
+ environmentId,
+ id: ProjectId.make("project-newer"),
+ title: "Newer project",
+ });
+ const scopes = buildHomeProjectScopes({
+ projects: [newerProject, olderProject],
+ environmentId: null,
+ projectGroupingMode: "separate",
+ });
+
+ expect(
+ sortHomeProjectScopes({
+ scopes,
+ threads: [
+ makeThread({
+ environmentId,
+ id: ThreadId.make("thread-older-project"),
+ projectId: olderProject.id,
+ title: "Most recently active",
+ updatedAt: "2026-06-03T00:00:00.000Z",
+ }),
+ makeThread({
+ environmentId,
+ id: ThreadId.make("thread-newer-project"),
+ projectId: newerProject.id,
+ title: "Less recently active",
+ updatedAt: "2026-06-02T00:00:00.000Z",
+ }),
+ ],
+ pendingTasks: [],
+ projectSortOrder: "updated_at",
+ }).map((scope) => scope.representative.id),
+ ).toEqual([olderProject.id, newerProject.id]);
+ });
+
+ it("sorts invalid project creation timestamps after valid ones", () => {
+ const environmentId = EnvironmentId.make("environment-1");
+ const invalidProject = makeProject({
+ environmentId,
+ id: ProjectId.make("project-invalid"),
+ title: "A invalid timestamp",
+ createdAt: "invalid",
+ });
+ const validProject = makeProject({
+ environmentId,
+ id: ProjectId.make("project-valid"),
+ title: "Z valid timestamp",
+ createdAt: "2026-06-02T00:00:00.000Z",
+ });
+ const scopes = buildHomeProjectScopes({
+ projects: [invalidProject, validProject],
+ environmentId: null,
+ projectGroupingMode: "separate",
+ });
+
+ expect(
+ sortHomeProjectScopes({
+ scopes,
+ threads: [],
+ pendingTasks: [],
+ projectSortOrder: "created_at",
+ }).map((scope) => scope.representative.id),
+ ).toEqual([validProject.id, invalidProject.id]);
+ });
+
+ it("uses the freshest member when a grouped scope has no activity", () => {
+ const localEnvironmentId = EnvironmentId.make("environment-local");
+ const remoteEnvironmentId = EnvironmentId.make("environment-remote");
+ const repositoryIdentity = {
+ canonicalKey: "github.com/pingdotgg/t3code",
+ locator: {
+ source: "git-remote" as const,
+ remoteName: "origin",
+ remoteUrl: "git@github.com:pingdotgg/t3code.git",
+ },
+ };
+ const olderMember = makeProject({
+ environmentId: localEnvironmentId,
+ id: ProjectId.make("project-older-member"),
+ title: "t3code",
+ updatedAt: "2026-06-01T00:00:00.000Z",
+ repositoryIdentity,
+ });
+ const newerMember = makeProject({
+ environmentId: remoteEnvironmentId,
+ id: ProjectId.make("project-newer-member"),
+ title: "t3code",
+ updatedAt: "2026-06-03T00:00:00.000Z",
+ repositoryIdentity,
+ });
+ const otherProject = makeProject({
+ environmentId: localEnvironmentId,
+ id: ProjectId.make("project-other"),
+ title: "other",
+ updatedAt: "2026-06-02T00:00:00.000Z",
+ });
+ const scopes = buildHomeProjectScopes({
+ projects: [olderMember, newerMember, otherProject],
+ environmentId: null,
+ projectGroupingMode: "repository",
+ });
+
+ expect(
+ sortHomeProjectScopes({
+ scopes,
+ threads: [],
+ pendingTasks: [],
+ projectSortOrder: "updated_at",
+ })[0]?.key,
+ ).toBe(scopes.find((scope) => scope.projects.length === 2)?.key);
+ });
+
+ it("does not merge unrelated repositories that share a title", () => {
+ const environmentId = EnvironmentId.make("environment-1");
+ const projects = ["one", "two"].map((name) =>
+ makeProject({
+ environmentId,
+ id: ProjectId.make(`project-${name}`),
+ title: "app",
+ repositoryIdentity: {
+ canonicalKey: `github.com/example/${name}`,
+ locator: {
+ source: "git-remote" as const,
+ remoteName: "origin",
+ remoteUrl: `git@github.com:example/${name}.git`,
+ },
+ },
+ }),
+ );
+
+ expect(
+ buildHomeProjectScopes({
+ projects,
+ environmentId: null,
+ projectGroupingMode: "repository",
+ }),
+ ).toHaveLength(2);
+ });
+
it("sorts the newest thread first regardless of snapshot order", () => {
const environmentId = EnvironmentId.make("environment-1");
const project = makeProject({
diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts
index cada956d8bb..dffd94c76bd 100644
--- a/apps/mobile/src/features/home/homeThreadList.ts
+++ b/apps/mobile/src/features/home/homeThreadList.ts
@@ -1,14 +1,20 @@
import {
deriveLogicalProjectKey,
+ derivePhysicalProjectKey,
deriveProjectGroupLabel,
} from "@t3tools/client-runtime/state/project-grouping";
import type {
EnvironmentProject,
EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
-import { getThreadSortTimestamp, sortThreads } from "@t3tools/client-runtime/state/thread-sort";
+import {
+ getThreadSortTimestamp,
+ sortThreads,
+ toSortableTimestamp,
+} from "@t3tools/client-runtime/state/thread-sort";
import type {
EnvironmentId,
+ ScopedProjectRef,
SidebarProjectGroupingMode,
SidebarProjectSortOrder,
SidebarThreadSortOrder,
@@ -22,6 +28,164 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
export type HomeProjectSortOrder = Exclude;
+export interface HomeProjectScope {
+ readonly key: string;
+ readonly title: string;
+ readonly representative: EnvironmentProject;
+ readonly projects: ReadonlyArray;
+ readonly projectRefs: ReadonlyArray;
+}
+
+function getProjectFreshnessTimestamp(project: EnvironmentProject): number {
+ return toSortableTimestamp(project.updatedAt) ?? toSortableTimestamp(project.createdAt) ?? 0;
+}
+
+function getProjectSortTimestamp(
+ project: EnvironmentProject,
+ sortOrder: HomeProjectSortOrder,
+): number {
+ return sortOrder === "created_at"
+ ? (toSortableTimestamp(project.createdAt) ?? Number.NEGATIVE_INFINITY)
+ : (toSortableTimestamp(project.updatedAt) ??
+ toSortableTimestamp(project.createdAt) ??
+ Number.NEGATIVE_INFINITY);
+}
+
+export function buildHomeProjectScopes(input: {
+ readonly projects: ReadonlyArray;
+ readonly environmentId: EnvironmentId | null;
+ readonly projectGroupingMode: SidebarProjectGroupingMode;
+}): ReadonlyArray {
+ const projects = input.projects.filter(
+ (project) => input.environmentId === null || project.environmentId === input.environmentId,
+ );
+ const projectsByPhysicalKey = new Map();
+ for (const project of projects) {
+ const physicalKey = derivePhysicalProjectKey(project);
+ const existing = projectsByPhysicalKey.get(physicalKey);
+ if (existing) existing.push(project);
+ else projectsByPhysicalKey.set(physicalKey, [project]);
+ }
+
+ const winnersByPhysicalKey = new Map<
+ string,
+ { readonly key: string; readonly project: EnvironmentProject }
+ >();
+ for (const [physicalKey, members] of projectsByPhysicalKey) {
+ const project = members.reduce((winner, candidate) => {
+ const freshnessDelta =
+ getProjectFreshnessTimestamp(candidate) - getProjectFreshnessTimestamp(winner);
+ return freshnessDelta > 0 || (freshnessDelta === 0 && candidate.id > winner.id)
+ ? candidate
+ : winner;
+ });
+ const identitySource = members.find((member) => member.repositoryIdentity !== null) ?? project;
+ winnersByPhysicalKey.set(physicalKey, {
+ key: deriveLogicalProjectKey(identitySource, { groupingMode: input.projectGroupingMode }),
+ project,
+ });
+ }
+
+ const groups = new Map();
+ for (const { key, project } of winnersByPhysicalKey.values()) {
+ const existing = groups.get(key);
+ if (existing) existing.push(project);
+ else groups.set(key, [project]);
+ }
+
+ const projectRefsByGroup = new Map();
+ const seenProjectRefs = new Set();
+ for (const project of projects) {
+ const refKey = scopedProjectKey(project.environmentId, project.id);
+ if (seenProjectRefs.has(refKey)) continue;
+ seenProjectRefs.add(refKey);
+
+ const key =
+ winnersByPhysicalKey.get(derivePhysicalProjectKey(project))?.key ??
+ deriveLogicalProjectKey(project, { groupingMode: input.projectGroupingMode });
+ const refs = projectRefsByGroup.get(key);
+ const projectRef = { environmentId: project.environmentId, projectId: project.id };
+ if (refs) refs.push(projectRef);
+ else projectRefsByGroup.set(key, [projectRef]);
+ }
+
+ return Array.from(groups, ([key, projects]) => {
+ const representative = projects[0]!;
+ return {
+ key,
+ title:
+ projects.length > 1
+ ? deriveProjectGroupLabel({ representative, members: projects })
+ : representative.title,
+ representative,
+ projects,
+ projectRefs: projectRefsByGroup.get(key) ?? [],
+ };
+ });
+}
+
+export function sortHomeProjectScopes(input: {
+ readonly scopes: ReadonlyArray;
+ readonly threads: ReadonlyArray;
+ readonly pendingTasks: ReadonlyArray;
+ readonly projectSortOrder: HomeProjectSortOrder;
+}): ReadonlyArray {
+ const scopeKeyByProjectRef = new Map(
+ input.scopes.flatMap((scope) =>
+ scope.projectRefs.map(
+ (projectRef) =>
+ [scopedProjectKey(projectRef.environmentId, projectRef.projectId), scope.key] as const,
+ ),
+ ),
+ );
+ const latestActivityByScope = new Map();
+ const recordActivity = (scopeKey: string | undefined, timestamp: number) => {
+ if (!scopeKey || !Number.isFinite(timestamp)) return;
+ latestActivityByScope.set(
+ scopeKey,
+ Math.max(latestActivityByScope.get(scopeKey) ?? Number.NEGATIVE_INFINITY, timestamp),
+ );
+ };
+
+ for (const thread of input.threads) {
+ if (thread.archivedAt !== null) continue;
+ recordActivity(
+ scopeKeyByProjectRef.get(scopedProjectKey(thread.environmentId, thread.projectId)),
+ getThreadSortTimestamp(thread, input.projectSortOrder),
+ );
+ }
+ for (const pendingTask of input.pendingTasks) {
+ recordActivity(
+ scopeKeyByProjectRef.get(
+ scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
+ ),
+ Date.parse(pendingTask.message.createdAt),
+ );
+ }
+
+ return Arr.sort(
+ input.scopes,
+ Order.mapInput(
+ Order.Struct({
+ timestamp: Order.flip(Order.Number),
+ title: Order.String,
+ key: Order.String,
+ }),
+ (scope: HomeProjectScope) => ({
+ timestamp:
+ latestActivityByScope.get(scope.key) ??
+ Math.max(
+ ...scope.projects.map((project) =>
+ getProjectSortTimestamp(project, input.projectSortOrder),
+ ),
+ ),
+ title: scope.title,
+ key: scope.key,
+ }),
+ ),
+ );
+}
+
/**
* Default home view only surfaces threads active within this window, to keep the
* screen compact while keeping recent work visible.
@@ -103,22 +267,18 @@ export function buildHomeThreadGroups(input: {
const groups = new Map();
const groupKeyByProjectKey = new Map();
- for (const project of input.projects) {
- if (input.environmentId !== null && project.environmentId !== input.environmentId) {
- continue;
- }
-
- const groupKey = deriveLogicalProjectKey(project, {
- groupingMode: input.projectGroupingMode,
+ for (const scope of buildHomeProjectScopes(input)) {
+ groups.set(scope.key, {
+ key: scope.key,
+ projects: [...scope.projects],
+ pendingTasks: [],
+ threads: [],
});
- const physicalKey = scopedProjectKey(project.environmentId, project.id);
- groupKeyByProjectKey.set(physicalKey, groupKey);
-
- const existing = groups.get(groupKey);
- if (existing) {
- existing.projects.push(project);
- } else {
- groups.set(groupKey, { key: groupKey, projects: [project], pendingTasks: [], threads: [] });
+ for (const projectRef of scope.projectRefs) {
+ groupKeyByProjectKey.set(
+ scopedProjectKey(projectRef.environmentId, projectRef.projectId),
+ scope.key,
+ );
}
}
@@ -215,21 +375,23 @@ export function buildHomeThreadGroups(input: {
? selectRecentThreads(sortedThreads, input.threadSortOrder, now)
: sortedThreads;
- // Sorted newest-first, so the first thread whose project is a group member
- // marks the machine the user last worked on.
- const lastActiveProject = Arr.findFirst(sortedThreads, (thread) =>
- group.projects.some(
- (project) =>
- project.environmentId === thread.environmentId && project.id === thread.projectId,
- ),
- ).pipe(
+ // A stale project id still resolves to the canonical member with the same
+ // environment/path, so quick creation follows the machine with the newest activity.
+ const lastActiveProject = Arr.head(sortedThreads).pipe(
Option.flatMap((thread) =>
Arr.findFirst(
- group.projects,
+ input.projects,
(project) =>
project.environmentId === thread.environmentId && project.id === thread.projectId,
),
),
+ Option.flatMap((threadProject) =>
+ Arr.findFirst(
+ group.projects,
+ (project) =>
+ derivePhysicalProjectKey(project) === derivePhysicalProjectKey(threadProject),
+ ),
+ ),
Option.getOrNull,
);
diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index 13f628351c9..0a14b7847c8 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -48,7 +48,7 @@ import {
type HomeGroupDisplayState,
type HomeListItem,
} from "../home/homeListItems";
-import { buildHomeThreadGroups } from "../home/homeThreadList";
+import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThreadList";
import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";
import { usePendingTaskListActions } from "../home/usePendingTaskListActions";
import { useThreadListActions } from "../home/useThreadListActions";
@@ -225,28 +225,29 @@ function ThreadNavigationSidebarPane(
setThreadSortOrder,
} = useHomeListOptions(availableEnvironmentIds);
const [selectedProjectKey, setSelectedProjectKey] = useState(null);
+ const projectScopes = useMemo(
+ () =>
+ buildHomeProjectScopes({
+ projects,
+ environmentId: options.selectedEnvironmentId,
+ projectGroupingMode: options.projectGroupingMode,
+ }),
+ [options.projectGroupingMode, options.selectedEnvironmentId, projects],
+ );
const projectFilterOptions = useMemo(
() =>
- projects
- .filter(
- (project) =>
- options.selectedEnvironmentId === null ||
- project.environmentId === options.selectedEnvironmentId,
- )
- .map((project) => ({
- key: scopedProjectKey(project.environmentId, project.id),
- label: project.title,
- })),
- [options.selectedEnvironmentId, projects],
+ projectScopes.map((scope) => ({
+ key: scope.key,
+ label: scope.title,
+ })),
+ [projectScopes],
);
- const selectedProject = useMemo(
+ const selectedProjectScope = useMemo(
() =>
selectedProjectKey === null
? null
- : (projects.find(
- (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey,
- ) ?? null),
- [projects, selectedProjectKey],
+ : (projectScopes.find((scope) => scope.key === selectedProjectKey) ?? null),
+ [projectScopes, selectedProjectKey],
);
useEffect(() => {
if (
@@ -256,31 +257,45 @@ function ThreadNavigationSidebarPane(
setSelectedProjectKey(null);
}
}, [projectFilterOptions, selectedProjectKey]);
+ const selectedProjectRefs = useMemo(
+ () =>
+ selectedProjectScope === null
+ ? null
+ : new Set(
+ selectedProjectScope.projectRefs.map((projectRef) =>
+ scopedProjectKey(projectRef.environmentId, projectRef.projectId),
+ ),
+ ),
+ [selectedProjectScope],
+ );
const scopedProjects = useMemo(
- () => (selectedProject === null ? projects : [selectedProject]),
- [projects, selectedProject],
+ () =>
+ selectedProjectRefs === null
+ ? projects
+ : projects.filter((project) =>
+ selectedProjectRefs.has(scopedProjectKey(project.environmentId, project.id)),
+ ),
+ [projects, selectedProjectRefs],
);
const scopedThreads = useMemo(
() =>
- selectedProject === null
+ selectedProjectRefs === null
? threads
- : threads.filter(
- (thread) =>
- thread.environmentId === selectedProject.environmentId &&
- thread.projectId === selectedProject.id,
+ : threads.filter((thread) =>
+ selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)),
),
- [selectedProject, threads],
+ [selectedProjectRefs, threads],
);
const scopedPendingTasks = useMemo(
() =>
- selectedProject === null
+ selectedProjectRefs === null
? pendingTasks
- : pendingTasks.filter(
- (pendingTask) =>
- pendingTask.message.environmentId === selectedProject.environmentId &&
- pendingTask.creation.projectId === selectedProject.id,
+ : pendingTasks.filter((pendingTask) =>
+ selectedProjectRefs.has(
+ scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
+ ),
),
- [pendingTasks, selectedProject],
+ [pendingTasks, selectedProjectRefs],
);
const groups = useMemo(
() =>
@@ -401,13 +416,7 @@ function ThreadNavigationSidebarPane(
return buildThreadListV2Items({
threads: threads.filter((thread) => thread.archivedAt === null),
environmentId: options.selectedEnvironmentId,
- projectRef:
- selectedProject === null
- ? null
- : {
- environmentId: selectedProject.environmentId,
- projectId: selectedProject.id,
- },
+ projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,
searchQuery: props.searchQuery,
changeRequestStateByKey,
settlementEnvironmentIds,
@@ -423,7 +432,7 @@ function ThreadNavigationSidebarPane(
settlementEnvironmentIds,
threadListV2Enabled,
threads,
- selectedProject,
+ selectedProjectScope,
]);
const listItems = useMemo(() => {
if (!threadListV2Enabled) return listLayout.items;
@@ -437,9 +446,10 @@ function ThreadNavigationSidebarPane(
(pendingTask) =>
(options.selectedEnvironmentId === null ||
pendingTask.message.environmentId === options.selectedEnvironmentId) &&
- (selectedProject === null ||
- (pendingTask.message.environmentId === selectedProject.environmentId &&
- pendingTask.creation.projectId === selectedProject.id)) &&
+ (selectedProjectRefs === null ||
+ selectedProjectRefs.has(
+ scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
+ )) &&
(v2SearchQuery.length === 0 ||
pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
);
@@ -469,7 +479,7 @@ function ThreadNavigationSidebarPane(
options.selectedEnvironmentId,
pendingTasks,
props.searchQuery,
- selectedProject,
+ selectedProjectRefs,
threadListV2Enabled,
threadListV2Layout,
]);
@@ -933,8 +943,8 @@ function ThreadNavigationSidebarPane(
? "Loading threads…"
: props.searchQuery.trim().length > 0
? "No matching threads"
- : selectedProject !== null
- ? `No threads in ${selectedProject.title}`
+ : selectedProjectScope !== null
+ ? `No threads in ${selectedProjectScope.title}`
: "No threads yet"}
);
diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts
index 80edc86124b..cdbf2e3c585 100644
--- a/apps/mobile/src/features/threads/threadListV2.test.ts
+++ b/apps/mobile/src/features/threads/threadListV2.test.ts
@@ -166,13 +166,36 @@ describe("buildThreadListV2Items", () => {
}),
],
environmentId: null,
- projectRef: { environmentId, projectId: ProjectId.make("project-1") },
+ projectRefs: [{ environmentId, projectId: ProjectId.make("project-1") }],
searchQuery: "",
now: NOW,
});
expect(items.map((item) => item.thread.id)).toEqual(["included"]);
});
+
+ it("scopes the flat list to every environment member of a logical project", () => {
+ const remoteEnvironmentId = EnvironmentId.make("environment-remote");
+ const { items } = buildThreadListV2Items({
+ threads: [
+ makeThread({ id: ThreadId.make("local"), title: "Local" }),
+ makeThread({
+ environmentId: remoteEnvironmentId,
+ id: ThreadId.make("remote"),
+ title: "Remote",
+ }),
+ ],
+ environmentId: null,
+ projectRefs: [
+ { environmentId, projectId: ProjectId.make("project-1") },
+ { environmentId: remoteEnvironmentId, projectId: ProjectId.make("project-1") },
+ ],
+ searchQuery: "",
+ now: NOW,
+ });
+
+ expect(items.map((item) => item.thread.id)).toEqual(["local", "remote"]);
+ });
});
describe("buildThreadListV2Items settled paging", () => {
diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts
index a1604bafac4..071e1d93be0 100644
--- a/apps/mobile/src/features/threads/threadListV2.ts
+++ b/apps/mobile/src/features/threads/threadListV2.ts
@@ -95,10 +95,10 @@ export interface ThreadListV2Layout {
export function buildThreadListV2Items(input: {
readonly threads: ReadonlyArray;
readonly environmentId: EnvironmentId | null;
- readonly projectRef?: {
+ readonly projectRefs?: ReadonlyArray<{
readonly environmentId: EnvironmentId;
readonly projectId: ProjectId;
- } | null;
+ }> | null;
readonly searchQuery: string;
/** Per-row PR state reported up by visible rows ("env:threadId" keys). */
readonly changeRequestStateByKey?: ReadonlyMap;
@@ -115,6 +115,9 @@ export function buildThreadListV2Items(input: {
const now = input.now ?? new Date().toISOString();
const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
const query = input.searchQuery.trim().toLocaleLowerCase();
+ const projectKeys = input.projectRefs
+ ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
+ : null;
const active: EnvironmentThreadShell[] = [];
const settled: EnvironmentThreadShell[] = [];
@@ -122,11 +125,7 @@ export function buildThreadListV2Items(input: {
// Callers pass live (unarchived) shells; settled threads are among them
// and partition into the tail via effectiveSettled.
if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue;
- if (
- input.projectRef != null &&
- (thread.environmentId !== input.projectRef.environmentId ||
- thread.projectId !== input.projectRef.projectId)
- ) {
+ if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) {
continue;
}
if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue;