From 987d79a86e0e9b03ac41787894105d6fa1b3ee60 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 23 Jul 2026 00:17:42 +0200 Subject: [PATCH 1/4] group project scopes in mobile thread lists Co-authored-by: codex --- apps/mobile/src/features/home/HomeScreen.tsx | 72 +++--- .../src/features/home/homeThreadList.test.ts | 212 +++++++++++++++++- .../src/features/home/homeThreadList.ts | 201 ++++++++++++++--- .../threads/ThreadNavigationSidebar.tsx | 12 +- .../src/features/threads/threadListV2.test.ts | 25 ++- .../src/features/threads/threadListV2.ts | 13 +- 6 files changed, 470 insertions(+), 65 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 41180c48643..879ccb14f07 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,45 @@ 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, - ) ?? null), + : (v2ScopeProjects.find((project) => project.key === v2ProjectScopeKey) ?? null), [v2ProjectScopeKey, v2ScopeProjects], ); + const v2ScopedProjectKeys = useMemo( + () => + v2ScopedProjectGroup === null + ? null + : new Set( + v2ScopedProjectGroup.projects.map((project) => + scopedProjectKey(project.environmentId, project.id), + ), + ), + [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 +456,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 +472,7 @@ export function HomeScreen(props: HomeScreenProps) { props.selectedEnvironmentId, props.threads, threadListV2Enabled, - v2ScopedProject, + v2ScopedProjectGroup, ]); const threadListV2Items = threadListV2Layout.items; @@ -699,9 +718,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 +770,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..884fe7d5dac 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,212 @@ 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("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("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..3e8fc663cc3 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,153 @@ 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 winnersByPhysicalKey = new Map< + string, + { readonly key: string; readonly project: EnvironmentProject } + >(); + for (const project of projects) { + const physicalKey = derivePhysicalProjectKey(project); + const existing = winnersByPhysicalKey.get(physicalKey); + if ( + !existing || + getProjectFreshnessTimestamp(project) > getProjectFreshnessTimestamp(existing.project) || + (getProjectFreshnessTimestamp(project) === getProjectFreshnessTimestamp(existing.project) && + project.id > existing.project.id) + ) { + winnersByPhysicalKey.set(physicalKey, { + key: deriveLogicalProjectKey(project, { 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) ?? + getProjectSortTimestamp(scope.representative, 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 +256,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 +364,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..14ed0c61e1a 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -401,13 +401,15 @@ function ThreadNavigationSidebarPane( return buildThreadListV2Items({ threads: threads.filter((thread) => thread.archivedAt === null), environmentId: options.selectedEnvironmentId, - projectRef: + projectRefs: selectedProject === null ? null - : { - environmentId: selectedProject.environmentId, - projectId: selectedProject.id, - }, + : [ + { + environmentId: selectedProject.environmentId, + projectId: selectedProject.id, + }, + ], searchQuery: props.searchQuery, changeRequestStateByKey, settlementEnvironmentIds, 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; From d089f8f49f16b31a4558c004ac4553a2d9bd4f75 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 23 Jul 2026 01:16:32 +0200 Subject: [PATCH 2/4] fix grouped mobile project scoping Co-authored-by: codex --- apps/mobile/src/features/home/HomeScreen.tsx | 14 ++++-- .../src/features/home/homeThreadList.test.ts | 47 +++++++++++++++++++ .../src/features/home/homeThreadList.ts | 6 ++- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 879ccb14f07..86d6eeff051 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -362,7 +362,15 @@ export function HomeScreen(props: HomeScreenProps) { () => v2ProjectScopeKey === null ? null - : (v2ScopeProjects.find((project) => project.key === v2ProjectScopeKey) ?? null), + : (v2ScopeProjects.find( + (scope) => + scope.key === v2ProjectScopeKey || + scope.projectRefs.some( + (projectRef) => + scopedProjectKey(projectRef.environmentId, projectRef.projectId) === + v2ProjectScopeKey, + ), + ) ?? null), [v2ProjectScopeKey, v2ScopeProjects], ); const v2ScopedProjectKeys = useMemo( @@ -370,8 +378,8 @@ export function HomeScreen(props: HomeScreenProps) { v2ScopedProjectGroup === null ? null : new Set( - v2ScopedProjectGroup.projects.map((project) => - scopedProjectKey(project.environmentId, project.id), + v2ScopedProjectGroup.projectRefs.map((projectRef) => + scopedProjectKey(projectRef.environmentId, projectRef.projectId), ), ), [v2ScopedProjectGroup], diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 884fe7d5dac..2a29be19cf1 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -250,6 +250,53 @@ describe("buildHomeThreadGroups", () => { ).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) => diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 3e8fc663cc3..375d3ea980d 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -167,7 +167,11 @@ export function sortHomeProjectScopes(input: { (scope: HomeProjectScope) => ({ timestamp: latestActivityByScope.get(scope.key) ?? - getProjectSortTimestamp(scope.representative, input.projectSortOrder), + Math.max( + ...scope.projects.map((project) => + getProjectSortTimestamp(project, input.projectSortOrder), + ), + ), title: scope.title, key: scope.key, }), From 7e09d54f0eb8b6f07cbee9d2f5bead3d680e9f9a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 23 Jul 2026 14:33:54 +0200 Subject: [PATCH 3/4] fix logical project scopes on mobile Co-authored-by: codex --- .../src/features/home/homeThreadList.test.ts | 49 ++++++++++ .../src/features/home/homeThreadList.ts | 35 ++++--- .../threads/ThreadNavigationSidebar.tsx | 97 ++++++++++--------- 3 files changed, 120 insertions(+), 61 deletions(-) diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 2a29be19cf1..ffddbdcc86b 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -177,6 +177,55 @@ describe("buildHomeThreadGroups", () => { 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({ diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 375d3ea980d..dffd94c76bd 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -59,24 +59,31 @@ export function buildHomeProjectScopes(input: { 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 project of projects) { - const physicalKey = derivePhysicalProjectKey(project); - const existing = winnersByPhysicalKey.get(physicalKey); - if ( - !existing || - getProjectFreshnessTimestamp(project) > getProjectFreshnessTimestamp(existing.project) || - (getProjectFreshnessTimestamp(project) === getProjectFreshnessTimestamp(existing.project) && - project.id > existing.project.id) - ) { - winnersByPhysicalKey.set(physicalKey, { - key: deriveLogicalProjectKey(project, { groupingMode: input.projectGroupingMode }), - project, - }); - } + 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(); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 14ed0c61e1a..e6a33002a06 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 ( @@ -257,30 +258,39 @@ function ThreadNavigationSidebarPane( } }, [projectFilterOptions, selectedProjectKey]); const scopedProjects = useMemo( - () => (selectedProject === null ? projects : [selectedProject]), - [projects, selectedProject], + () => (selectedProjectScope === null ? projects : selectedProjectScope.projects), + [projects, selectedProjectScope], + ); + const selectedProjectRefs = useMemo( + () => + selectedProjectScope === null + ? null + : new Set( + selectedProjectScope.projectRefs.map((projectRef) => + scopedProjectKey(projectRef.environmentId, projectRef.projectId), + ), + ), + [selectedProjectScope], ); 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,15 +411,7 @@ function ThreadNavigationSidebarPane( return buildThreadListV2Items({ threads: threads.filter((thread) => thread.archivedAt === null), environmentId: options.selectedEnvironmentId, - projectRefs: - selectedProject === null - ? null - : [ - { - environmentId: selectedProject.environmentId, - projectId: selectedProject.id, - }, - ], + projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, changeRequestStateByKey, settlementEnvironmentIds, @@ -425,7 +427,7 @@ function ThreadNavigationSidebarPane( settlementEnvironmentIds, threadListV2Enabled, threads, - selectedProject, + selectedProjectScope, ]); const listItems = useMemo(() => { if (!threadListV2Enabled) return listLayout.items; @@ -439,9 +441,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)), ); @@ -471,7 +474,7 @@ function ThreadNavigationSidebarPane( options.selectedEnvironmentId, pendingTasks, props.searchQuery, - selectedProject, + selectedProjectRefs, threadListV2Enabled, threadListV2Layout, ]); @@ -935,8 +938,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"} ); From c71bc867a1d5ac5c3067478779774698308cd9f1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 23 Jul 2026 14:54:30 +0200 Subject: [PATCH 4/4] keep stale project refs in mobile filters Co-authored-by: codex --- .../features/threads/ThreadNavigationSidebar.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index e6a33002a06..0a14b7847c8 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -257,10 +257,6 @@ function ThreadNavigationSidebarPane( setSelectedProjectKey(null); } }, [projectFilterOptions, selectedProjectKey]); - const scopedProjects = useMemo( - () => (selectedProjectScope === null ? projects : selectedProjectScope.projects), - [projects, selectedProjectScope], - ); const selectedProjectRefs = useMemo( () => selectedProjectScope === null @@ -272,6 +268,15 @@ function ThreadNavigationSidebarPane( ), [selectedProjectScope], ); + const scopedProjects = useMemo( + () => + selectedProjectRefs === null + ? projects + : projects.filter((project) => + selectedProjectRefs.has(scopedProjectKey(project.environmentId, project.id)), + ), + [projects, selectedProjectRefs], + ); const scopedThreads = useMemo( () => selectedProjectRefs === null