| author | |
| committer | |
| log | 0542abc75e378428165cf8a492d02dd4cc3dc348 |
| tree | f8cd6b65f80b1c1cb077f755d6bd47d280565f56 |
| parent | 7609495bf70a51dd4d8608e3f71da636a7348aa6 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>6 files changed, 631 insertions(+), 103 deletions(-)
apps/mobile/src/features/home/HomeScreen.tsx+52-24| ... | @@ -15,17 +15,15 @@ import type { | ... | @@ -15,17 +15,15 @@ import type { |
| 15 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | 15 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 16 | import { AsyncResult } from "effect/unstable/reactivity"; | 16 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 17 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 17 | 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"; |
| 19 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; | 19 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| 20 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 20 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 21 | import { useThemeColor } from "../../lib/useThemeColor"; | 21 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 22 | 22 | ||
| 23 | import { AppText as Text } from "../../components/AppText"; | 23 | import { AppText as Text } from "../../components/AppText"; |
| 24 | import { EmptyState } from "../../components/EmptyState"; | 24 | import { EmptyState } from "../../components/EmptyState"; |
| 25 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | ||
| 26 | import type { WorkspaceState } from "../../state/workspaceModel"; | 25 | import type { WorkspaceState } from "../../state/workspaceModel"; |
| 27 | import type { SavedRemoteConnection } from "../../lib/connection"; | 26 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 28 | import { cn } from "../../lib/cn"; | ||
| 29 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 27 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 30 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | 28 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 31 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | 29 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| ... | @@ -54,7 +52,12 @@ import { | ... | @@ -54,7 +52,12 @@ import { |
| 54 | type HomeGroupDisplayState, | 52 | type HomeGroupDisplayState, |
| 55 | type HomeListItem, | 53 | type HomeListItem, |
| 56 | } from "./homeListItems"; | 54 | } from "./homeListItems"; |
| 57 | import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList"; | 55 | import { |
| 56 | buildHomeProjectScopes, | ||
| 57 | buildHomeThreadGroups, | ||
| 58 | sortHomeProjectScopes, | ||
| 59 | type HomeProjectSortOrder, | ||
| 60 | } from "./homeThreadList"; | ||
| 58 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; | 61 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; |
| 59 | import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus"; | 62 | import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus"; |
| 60 | import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status"; | 63 | import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status"; |
| ... | @@ -334,23 +337,53 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -334,23 +337,53 @@ export function HomeScreen(props: HomeScreenProps) { |
| 334 | }, [props.projects]); | 337 | }, [props.projects]); |
| 335 | 338 | ||
| 336 | const v2ProjectScopeKey = props.selectedProjectKey; | 339 | const v2ProjectScopeKey = props.selectedProjectKey; |
| 337 | const setV2ProjectScopeKey = props.onProjectChange; | ||
| 338 | const v2ScopeProjects = useMemo( | 340 | const v2ScopeProjects = useMemo( |
| 339 | () => | 341 | () => |
| 340 | props.selectedEnvironmentId === null | 342 | sortHomeProjectScopes({ |
| 341 | ? props.projects | 343 | scopes: buildHomeProjectScopes({ |
| 342 | : props.projects.filter((project) => project.environmentId === props.selectedEnvironmentId), | 344 | projects: props.projects, |
| 343 | [props.projects, props.selectedEnvironmentId], | 345 | environmentId: props.selectedEnvironmentId, |
| 346 | projectGroupingMode: props.projectGroupingMode, | ||
| 347 | }), | ||
| 348 | threads: props.threads, | ||
| 349 | pendingTasks: props.pendingTasks, | ||
| 350 | projectSortOrder: props.projectSortOrder, | ||
| 351 | }), | ||
| 352 | [ | ||
| 353 | props.pendingTasks, | ||
| 354 | props.projectGroupingMode, | ||
| 355 | props.projects, | ||
| 356 | props.projectSortOrder, | ||
| 357 | props.selectedEnvironmentId, | ||
| 358 | props.threads, | ||
| 359 | ], | ||
| 344 | ); | 360 | ); |
| 345 | const v2ScopedProject = useMemo( | 361 | const v2ScopedProjectGroup = useMemo( |
| 346 | () => | 362 | () => |
| 347 | v2ProjectScopeKey === null | 363 | v2ProjectScopeKey === null |
| 348 | ? null | 364 | ? null |
| 349 | : (v2ScopeProjects.find( | 365 | : (v2ScopeProjects.find( |
| 350 | (project) => scopedProjectKey(project.environmentId, project.id) === v2ProjectScopeKey, | 366 | (scope) => |
| 367 | scope.key === v2ProjectScopeKey || | ||
| 368 | scope.projectRefs.some( | ||
| 369 | (projectRef) => | ||
| 370 | scopedProjectKey(projectRef.environmentId, projectRef.projectId) === | ||
| 371 | v2ProjectScopeKey, | ||
| 372 | ), | ||
| 351 | ) ?? null), | 373 | ) ?? null), |
| 352 | [v2ProjectScopeKey, v2ScopeProjects], | 374 | [v2ProjectScopeKey, v2ScopeProjects], |
| 353 | ); | 375 | ); |
| 376 | const v2ScopedProjectKeys = useMemo( | ||
| 377 | () => | ||
| 378 | v2ScopedProjectGroup === null | ||
| 379 | ? null | ||
| 380 | : new Set( | ||
| 381 | v2ScopedProjectGroup.projectRefs.map((projectRef) => | ||
| 382 | scopedProjectKey(projectRef.environmentId, projectRef.projectId), | ||
| 383 | ), | ||
| 384 | ), | ||
| 385 | [v2ScopedProjectGroup], | ||
| 386 | ); | ||
| 354 | // Thread List v2 (beta): one flat list in creation order, no grouping. | 387 | // Thread List v2 (beta): one flat list in creation order, no grouping. |
| 355 | // Settled threads collapse into a recency tail below the card block. | 388 | // Settled threads collapse into a recency tail below the card block. |
| 356 | // Settled threads stay in the live shell stream (settled ≠ archived), so | 389 | // Settled threads stay in the live shell stream (settled ≠ archived), so |
| ... | @@ -431,13 +464,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -431,13 +464,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 431 | return buildThreadListV2Items({ | 464 | return buildThreadListV2Items({ |
| 432 | threads: props.threads.filter((thread) => thread.archivedAt === null), | 465 | threads: props.threads.filter((thread) => thread.archivedAt === null), |
| 433 | environmentId: props.selectedEnvironmentId, | 466 | environmentId: props.selectedEnvironmentId, |
| 434 | projectRef: | 467 | projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, |
| 435 | v2ScopedProject === null | ||
| 436 | ? null | ||
| 437 | : { | ||
| 438 | environmentId: v2ScopedProject.environmentId, | ||
| 439 | projectId: v2ScopedProject.id, | ||
| 440 | }, | ||
| 441 | searchQuery: props.searchQuery, | 468 | searchQuery: props.searchQuery, |
| 442 | changeRequestStateByKey, | 469 | changeRequestStateByKey, |
| 443 | settlementEnvironmentIds, | 470 | settlementEnvironmentIds, |
| ... | @@ -453,7 +480,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -453,7 +480,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 453 | props.selectedEnvironmentId, | 480 | props.selectedEnvironmentId, |
| 454 | props.threads, | 481 | props.threads, |
| 455 | threadListV2Enabled, | 482 | threadListV2Enabled, |
| 456 | v2ScopedProject, | 483 | v2ScopedProjectGroup, |
| 457 | ]); | 484 | ]); |
| 458 | const threadListV2Items = threadListV2Layout.items; | 485 | const threadListV2Items = threadListV2Layout.items; |
| 459 | 486 | ||
| ... | @@ -699,9 +726,10 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -699,9 +726,10 @@ export function HomeScreen(props: HomeScreenProps) { |
| 699 | (pendingTask) => | 726 | (pendingTask) => |
| 700 | (props.selectedEnvironmentId === null || | 727 | (props.selectedEnvironmentId === null || |
| 701 | pendingTask.message.environmentId === props.selectedEnvironmentId) && | 728 | pendingTask.message.environmentId === props.selectedEnvironmentId) && |
| 702 | (v2ScopedProject === null || | 729 | (v2ScopedProjectKeys === null || |
| 703 | (pendingTask.message.environmentId === v2ScopedProject.environmentId && | 730 | v2ScopedProjectKeys.has( |
| 704 | pendingTask.creation.projectId === v2ScopedProject.id)) && | 731 | scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), |
| 732 | )) && | ||
| 705 | (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), | 733 | (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), |
| 706 | ); | 734 | ); |
| 707 | // Project scoping lives in the header filter menu (no inline chip row on | 735 | // Project scoping lives in the header filter menu (no inline chip row on |
| ... | @@ -750,9 +778,9 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -750,9 +778,9 @@ export function HomeScreen(props: HomeScreenProps) { |
| 750 | const v2ListEmpty = | 778 | const v2ListEmpty = |
| 751 | v2PendingTasks.length > 0 ? null : hasSearchQuery ? ( | 779 | v2PendingTasks.length > 0 ? null : hasSearchQuery ? ( |
| 752 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> | 780 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> |
| 753 | ) : v2ScopedProject !== null ? ( | 781 | ) : v2ScopedProjectGroup !== null ? ( |
| 754 | <EmptyState | 782 | <EmptyState |
| 755 | title={`No threads in ${v2ScopedProject.title}`} | 783 | title={`No threads in ${v2ScopedProjectGroup.title}`} |
| 756 | detail="Choose another project or create a new task." | 784 | detail="Choose another project or create a new task." |
| 757 | /> | 785 | /> |
| 758 | ) : ( | 786 | ) : ( |
apps/mobile/src/features/home/homeThreadList.test.ts+307-1| ... | @@ -5,7 +5,11 @@ import type { | ... | @@ -5,7 +5,11 @@ import type { |
| 5 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | 5 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 6 | import { describe, expect, it } from "vite-plus/test"; | 6 | import { describe, expect, it } from "vite-plus/test"; |
| 7 | 7 | ||
| 8 | import { buildHomeThreadGroups } from "./homeThreadList"; | 8 | import { |
| 9 | buildHomeProjectScopes, | ||
| 10 | buildHomeThreadGroups, | ||
| 11 | sortHomeProjectScopes, | ||
| 12 | } from "./homeThreadList"; | ||
| 9 | 13 | ||
| 10 | function makeProject( | 14 | function makeProject( |
| 11 | input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">, | 15 | input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">, |
| ... | @@ -67,6 +71,308 @@ function buildGroups( | ... | @@ -67,6 +71,308 @@ function buildGroups( |
| 67 | } | 71 | } |
| 68 | 72 | ||
| 69 | describe("buildHomeThreadGroups", () => { | 73 | 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 | |||
| 70 | it("sorts the newest thread first regardless of snapshot order", () => { | 376 | it("sorts the newest thread first regardless of snapshot order", () => { |
| 71 | const environmentId = EnvironmentId.make("environment-1"); | 377 | const environmentId = EnvironmentId.make("environment-1"); |
| 72 | const project = makeProject({ | 378 | const project = makeProject({ |
apps/mobile/src/features/home/homeThreadList.ts+187-25| ... | @@ -1,14 +1,20 @@ | ... | @@ -1,14 +1,20 @@ |
| 1 | import { | 1 | import { |
| 2 | deriveLogicalProjectKey, | 2 | deriveLogicalProjectKey, |
| 3 | derivePhysicalProjectKey, | ||
| 3 | deriveProjectGroupLabel, | 4 | deriveProjectGroupLabel, |
| 4 | } from "@t3tools/client-runtime/state/project-grouping"; | 5 | } from "@t3tools/client-runtime/state/project-grouping"; |
| 5 | import type { | 6 | import type { |
| 6 | EnvironmentProject, | 7 | EnvironmentProject, |
| 7 | EnvironmentThreadShell, | 8 | EnvironmentThreadShell, |
| 8 | } from "@t3tools/client-runtime/state/shell"; | 9 | } 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"; | ||
| 10 | import type { | 15 | import type { |
| 11 | EnvironmentId, | 16 | EnvironmentId, |
| 17 | ScopedProjectRef, | ||
| 12 | SidebarProjectGroupingMode, | 18 | SidebarProjectGroupingMode, |
| 13 | SidebarProjectSortOrder, | 19 | SidebarProjectSortOrder, |
| 14 | SidebarThreadSortOrder, | 20 | SidebarThreadSortOrder, |
| ... | @@ -22,6 +28,164 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | ... | @@ -22,6 +28,164 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 22 | 28 | ||
| 23 | export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">; | 29 | export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">; |
| 24 | 30 | ||
| 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 | |||
| 25 | /** | 189 | /** |
| 26 | * Default home view only surfaces threads active within this window, to keep the | 190 | * Default home view only surfaces threads active within this window, to keep the |
| 27 | * screen compact while keeping recent work visible. | 191 | * screen compact while keeping recent work visible. |
| ... | @@ -103,22 +267,18 @@ export function buildHomeThreadGroups(input: { | ... | @@ -103,22 +267,18 @@ export function buildHomeThreadGroups(input: { |
| 103 | const groups = new Map<string, MutableHomeThreadGroup>(); | 267 | const groups = new Map<string, MutableHomeThreadGroup>(); |
| 104 | const groupKeyByProjectKey = new Map<string, string>(); | 268 | const groupKeyByProjectKey = new Map<string, string>(); |
| 105 | 269 | ||
| 106 | for (const project of input.projects) { | 270 | for (const scope of buildHomeProjectScopes(input)) { |
| 107 | if (input.environmentId !== null && project.environmentId !== input.environmentId) { | 271 | groups.set(scope.key, { |
| 108 | continue; | 272 | key: scope.key, |
| 109 | } | 273 | projects: [...scope.projects], |
| 110 | 274 | pendingTasks: [], | |
| 111 | const groupKey = deriveLogicalProjectKey(project, { | 275 | threads: [], |
| 112 | groupingMode: input.projectGroupingMode, | ||
| 113 | }); | 276 | }); |
| 114 | const physicalKey = scopedProjectKey(project.environmentId, project.id); | 277 | for (const projectRef of scope.projectRefs) { |
| 115 | groupKeyByProjectKey.set(physicalKey, groupKey); | 278 | groupKeyByProjectKey.set( |
| 116 | 279 | scopedProjectKey(projectRef.environmentId, projectRef.projectId), | |
| 117 | const existing = groups.get(groupKey); | 280 | scope.key, |
| 118 | if (existing) { | 281 | ); |
| 119 | existing.projects.push(project); | ||
| 120 | } else { | ||
| 121 | groups.set(groupKey, { key: groupKey, projects: [project], pendingTasks: [], threads: [] }); | ||
| 122 | } | 282 | } |
| 123 | } | 283 | } |
| 124 | 284 | ||
| ... | @@ -215,21 +375,23 @@ export function buildHomeThreadGroups(input: { | ... | @@ -215,21 +375,23 @@ export function buildHomeThreadGroups(input: { |
| 215 | ? selectRecentThreads(sortedThreads, input.threadSortOrder, now) | 375 | ? selectRecentThreads(sortedThreads, input.threadSortOrder, now) |
| 216 | : sortedThreads; | 376 | : sortedThreads; |
| 217 | 377 | ||
| 218 | // Sorted newest-first, so the first thread whose project is a group member | 378 | // A stale project id still resolves to the canonical member with the same |
| 219 | // marks the machine the user last worked on. | 379 | // environment/path, so quick creation follows the machine with the newest activity. |
| 220 | const lastActiveProject = Arr.findFirst(sortedThreads, (thread) => | 380 | const lastActiveProject = Arr.head(sortedThreads).pipe( |
| 221 | group.projects.some( | ||
| 222 | (project) => | ||
| 223 | project.environmentId === thread.environmentId && project.id === thread.projectId, | ||
| 224 | ), | ||
| 225 | ).pipe( | ||
| 226 | Option.flatMap((thread) => | 381 | Option.flatMap((thread) => |
| 227 | Arr.findFirst( | 382 | Arr.findFirst( |
| 228 | group.projects, | 383 | input.projects, |
| 229 | (project) => | 384 | (project) => |
| 230 | project.environmentId === thread.environmentId && project.id === thread.projectId, | 385 | project.environmentId === thread.environmentId && project.id === thread.projectId, |
| 231 | ), | 386 | ), |
| 232 | ), | 387 | ), |
| 388 | Option.flatMap((threadProject) => | ||
| 389 | Arr.findFirst( | ||
| 390 | group.projects, | ||
| 391 | (project) => | ||
| 392 | derivePhysicalProjectKey(project) === derivePhysicalProjectKey(threadProject), | ||
| 393 | ), | ||
| 394 | ), | ||
| 233 | Option.getOrNull, | 395 | Option.getOrNull, |
| 234 | ); | 396 | ); |
| 235 | 397 |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+55-45| ... | @@ -48,7 +48,7 @@ import { | ... | @@ -48,7 +48,7 @@ import { |
| 48 | type HomeGroupDisplayState, | 48 | type HomeGroupDisplayState, |
| 49 | type HomeListItem, | 49 | type HomeListItem, |
| 50 | } from "../home/homeListItems"; | 50 | } from "../home/homeListItems"; |
| 51 | import { buildHomeThreadGroups } from "../home/homeThreadList"; | 51 | import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThreadList"; |
| 52 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; | 52 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; |
| 53 | import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; | 53 | import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; |
| 54 | import { useThreadListActions } from "../home/useThreadListActions"; | 54 | import { useThreadListActions } from "../home/useThreadListActions"; |
| ... | @@ -225,28 +225,29 @@ function ThreadNavigationSidebarPane( | ... | @@ -225,28 +225,29 @@ function ThreadNavigationSidebarPane( |
| 225 | setThreadSortOrder, | 225 | setThreadSortOrder, |
| 226 | } = useHomeListOptions(availableEnvironmentIds); | 226 | } = useHomeListOptions(availableEnvironmentIds); |
| 227 | const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); | 227 | const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); |
| 228 | const projectScopes = useMemo( | ||
| 229 | () => | ||
| 230 | buildHomeProjectScopes({ | ||
| 231 | projects, | ||
| 232 | environmentId: options.selectedEnvironmentId, | ||
| 233 | projectGroupingMode: options.projectGroupingMode, | ||
| 234 | }), | ||
| 235 | [options.projectGroupingMode, options.selectedEnvironmentId, projects], | ||
| 236 | ); | ||
| 228 | const projectFilterOptions = useMemo( | 237 | const projectFilterOptions = useMemo( |
| 229 | () => | 238 | () => |
| 230 | projects | 239 | projectScopes.map((scope) => ({ |
| 231 | .filter( | 240 | key: scope.key, |
| 232 | (project) => | 241 | label: scope.title, |
| 233 | options.selectedEnvironmentId === null || | 242 | })), |
| 234 | project.environmentId === options.selectedEnvironmentId, | 243 | [projectScopes], |
| 235 | ) | ||
| 236 | .map((project) => ({ | ||
| 237 | key: scopedProjectKey(project.environmentId, project.id), | ||
| 238 | label: project.title, | ||
| 239 | })), | ||
| 240 | [options.selectedEnvironmentId, projects], | ||
| 241 | ); | 244 | ); |
| 242 | const selectedProject = useMemo( | 245 | const selectedProjectScope = useMemo( |
| 243 | () => | 246 | () => |
| 244 | selectedProjectKey === null | 247 | selectedProjectKey === null |
| 245 | ? null | 248 | ? null |
| 246 | : (projects.find( | 249 | : (projectScopes.find((scope) => scope.key === selectedProjectKey) ?? null), |
| 247 | (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, | 250 | [projectScopes, selectedProjectKey], |
| 248 | ) ?? null), | ||
| 249 | [projects, selectedProjectKey], | ||
| 250 | ); | 251 | ); |
| 251 | useEffect(() => { | 252 | useEffect(() => { |
| 252 | if ( | 253 | if ( |
| ... | @@ -256,31 +257,45 @@ function ThreadNavigationSidebarPane( | ... | @@ -256,31 +257,45 @@ function ThreadNavigationSidebarPane( |
| 256 | setSelectedProjectKey(null); | 257 | setSelectedProjectKey(null); |
| 257 | } | 258 | } |
| 258 | }, [projectFilterOptions, selectedProjectKey]); | 259 | }, [projectFilterOptions, selectedProjectKey]); |
| 260 | const selectedProjectRefs = useMemo( | ||
| 261 | () => | ||
| 262 | selectedProjectScope === null | ||
| 263 | ? null | ||
| 264 | : new Set( | ||
| 265 | selectedProjectScope.projectRefs.map((projectRef) => | ||
| 266 | scopedProjectKey(projectRef.environmentId, projectRef.projectId), | ||
| 267 | ), | ||
| 268 | ), | ||
| 269 | [selectedProjectScope], | ||
| 270 | ); | ||
| 259 | const scopedProjects = useMemo( | 271 | const scopedProjects = useMemo( |
| 260 | () => (selectedProject === null ? projects : [selectedProject]), | 272 | () => |
| 261 | [projects, selectedProject], | 273 | selectedProjectRefs === null |
| 274 | ? projects | ||
| 275 | : projects.filter((project) => | ||
| 276 | selectedProjectRefs.has(scopedProjectKey(project.environmentId, project.id)), | ||
| 277 | ), | ||
| 278 | [projects, selectedProjectRefs], | ||
| 262 | ); | 279 | ); |
| 263 | const scopedThreads = useMemo( | 280 | const scopedThreads = useMemo( |
| 264 | () => | 281 | () => |
| 265 | selectedProject === null | 282 | selectedProjectRefs === null |
| 266 | ? threads | 283 | ? threads |
| 267 | : threads.filter( | 284 | : threads.filter((thread) => |
| 268 | (thread) => | 285 | selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), |
| 269 | thread.environmentId === selectedProject.environmentId && | ||
| 270 | thread.projectId === selectedProject.id, | ||
| 271 | ), | 286 | ), |
| 272 | [selectedProject, threads], | 287 | [selectedProjectRefs, threads], |
| 273 | ); | 288 | ); |
| 274 | const scopedPendingTasks = useMemo( | 289 | const scopedPendingTasks = useMemo( |
| 275 | () => | 290 | () => |
| 276 | selectedProject === null | 291 | selectedProjectRefs === null |
| 277 | ? pendingTasks | 292 | ? pendingTasks |
| 278 | : pendingTasks.filter( | 293 | : pendingTasks.filter((pendingTask) => |
| 279 | (pendingTask) => | 294 | selectedProjectRefs.has( |
| 280 | pendingTask.message.environmentId === selectedProject.environmentId && | 295 | scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), |
| 281 | pendingTask.creation.projectId === selectedProject.id, | 296 | ), |
| 282 | ), | 297 | ), |
| 283 | [pendingTasks, selectedProject], | 298 | [pendingTasks, selectedProjectRefs], |
| 284 | ); | 299 | ); |
| 285 | const groups = useMemo( | 300 | const groups = useMemo( |
| 286 | () => | 301 | () => |
| ... | @@ -401,13 +416,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -401,13 +416,7 @@ function ThreadNavigationSidebarPane( |
| 401 | return buildThreadListV2Items({ | 416 | return buildThreadListV2Items({ |
| 402 | threads: threads.filter((thread) => thread.archivedAt === null), | 417 | threads: threads.filter((thread) => thread.archivedAt === null), |
| 403 | environmentId: options.selectedEnvironmentId, | 418 | environmentId: options.selectedEnvironmentId, |
| 404 | projectRef: | 419 | projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, |
| 405 | selectedProject === null | ||
| 406 | ? null | ||
| 407 | : { | ||
| 408 | environmentId: selectedProject.environmentId, | ||
| 409 | projectId: selectedProject.id, | ||
| 410 | }, | ||
| 411 | searchQuery: props.searchQuery, | 420 | searchQuery: props.searchQuery, |
| 412 | changeRequestStateByKey, | 421 | changeRequestStateByKey, |
| 413 | settlementEnvironmentIds, | 422 | settlementEnvironmentIds, |
| ... | @@ -423,7 +432,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -423,7 +432,7 @@ function ThreadNavigationSidebarPane( |
| 423 | settlementEnvironmentIds, | 432 | settlementEnvironmentIds, |
| 424 | threadListV2Enabled, | 433 | threadListV2Enabled, |
| 425 | threads, | 434 | threads, |
| 426 | selectedProject, | 435 | selectedProjectScope, |
| 427 | ]); | 436 | ]); |
| 428 | const listItems = useMemo<readonly SidebarListItem[]>(() => { | 437 | const listItems = useMemo<readonly SidebarListItem[]>(() => { |
| 429 | if (!threadListV2Enabled) return listLayout.items; | 438 | if (!threadListV2Enabled) return listLayout.items; |
| ... | @@ -437,9 +446,10 @@ function ThreadNavigationSidebarPane( | ... | @@ -437,9 +446,10 @@ function ThreadNavigationSidebarPane( |
| 437 | (pendingTask) => | 446 | (pendingTask) => |
| 438 | (options.selectedEnvironmentId === null || | 447 | (options.selectedEnvironmentId === null || |
| 439 | pendingTask.message.environmentId === options.selectedEnvironmentId) && | 448 | pendingTask.message.environmentId === options.selectedEnvironmentId) && |
| 440 | (selectedProject === null || | 449 | (selectedProjectRefs === null || |
| 441 | (pendingTask.message.environmentId === selectedProject.environmentId && | 450 | selectedProjectRefs.has( |
| 442 | pendingTask.creation.projectId === selectedProject.id)) && | 451 | scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), |
| 452 | )) && | ||
| 443 | (v2SearchQuery.length === 0 || | 453 | (v2SearchQuery.length === 0 || |
| 444 | pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), | 454 | pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), |
| 445 | ); | 455 | ); |
| ... | @@ -469,7 +479,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -469,7 +479,7 @@ function ThreadNavigationSidebarPane( |
| 469 | options.selectedEnvironmentId, | 479 | options.selectedEnvironmentId, |
| 470 | pendingTasks, | 480 | pendingTasks, |
| 471 | props.searchQuery, | 481 | props.searchQuery, |
| 472 | selectedProject, | 482 | selectedProjectRefs, |
| 473 | threadListV2Enabled, | 483 | threadListV2Enabled, |
| 474 | threadListV2Layout, | 484 | threadListV2Layout, |
| 475 | ]); | 485 | ]); |
| ... | @@ -933,8 +943,8 @@ function ThreadNavigationSidebarPane( | ... | @@ -933,8 +943,8 @@ function ThreadNavigationSidebarPane( |
| 933 | ? "Loading threads…" | 943 | ? "Loading threads…" |
| 934 | : props.searchQuery.trim().length > 0 | 944 | : props.searchQuery.trim().length > 0 |
| 935 | ? "No matching threads" | 945 | ? "No matching threads" |
| 936 | : selectedProject !== null | 946 | : selectedProjectScope !== null |
| 937 | ? `No threads in ${selectedProject.title}` | 947 | ? `No threads in ${selectedProjectScope.title}` |
| 938 | : "No threads yet"} | 948 | : "No threads yet"} |
| 939 | </Text> | 949 | </Text> |
| 940 | ); | 950 | ); |
apps/mobile/src/features/threads/threadListV2.test.ts+24-1| ... | @@ -166,13 +166,36 @@ describe("buildThreadListV2Items", () => { | ... | @@ -166,13 +166,36 @@ describe("buildThreadListV2Items", () => { |
| 166 | }), | 166 | }), |
| 167 | ], | 167 | ], |
| 168 | environmentId: null, | 168 | environmentId: null, |
| 169 | projectRef: { environmentId, projectId: ProjectId.make("project-1") }, | 169 | projectRefs: [{ environmentId, projectId: ProjectId.make("project-1") }], |
| 170 | searchQuery: "", | 170 | searchQuery: "", |
| 171 | now: NOW, | 171 | now: NOW, |
| 172 | }); | 172 | }); |
| 173 | 173 | ||
| 174 | expect(items.map((item) => item.thread.id)).toEqual(["included"]); | 174 | expect(items.map((item) => item.thread.id)).toEqual(["included"]); |
| 175 | }); | 175 | }); |
| 176 | |||
| 177 | it("scopes the flat list to every environment member of a logical project", () => { | ||
| 178 | const remoteEnvironmentId = EnvironmentId.make("environment-remote"); | ||
| 179 | const { items } = buildThreadListV2Items({ | ||
| 180 | threads: [ | ||
| 181 | makeThread({ id: ThreadId.make("local"), title: "Local" }), | ||
| 182 | makeThread({ | ||
| 183 | environmentId: remoteEnvironmentId, | ||
| 184 | id: ThreadId.make("remote"), | ||
| 185 | title: "Remote", | ||
| 186 | }), | ||
| 187 | ], | ||
| 188 | environmentId: null, | ||
| 189 | projectRefs: [ | ||
| 190 | { environmentId, projectId: ProjectId.make("project-1") }, | ||
| 191 | { environmentId: remoteEnvironmentId, projectId: ProjectId.make("project-1") }, | ||
| 192 | ], | ||
| 193 | searchQuery: "", | ||
| 194 | now: NOW, | ||
| 195 | }); | ||
| 196 | |||
| 197 | expect(items.map((item) => item.thread.id)).toEqual(["local", "remote"]); | ||
| 198 | }); | ||
| 176 | }); | 199 | }); |
| 177 | 200 | ||
| 178 | describe("buildThreadListV2Items settled paging", () => { | 201 | describe("buildThreadListV2Items settled paging", () => { |
apps/mobile/src/features/threads/threadListV2.ts+6-7| ... | @@ -95,10 +95,10 @@ export interface ThreadListV2Layout { | ... | @@ -95,10 +95,10 @@ export interface ThreadListV2Layout { |
| 95 | export function buildThreadListV2Items(input: { | 95 | export function buildThreadListV2Items(input: { |
| 96 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | 96 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| 97 | readonly environmentId: EnvironmentId | null; | 97 | readonly environmentId: EnvironmentId | null; |
| 98 | readonly projectRef?: { | 98 | readonly projectRefs?: ReadonlyArray<{ |
| 99 | readonly environmentId: EnvironmentId; | 99 | readonly environmentId: EnvironmentId; |
| 100 | readonly projectId: ProjectId; | 100 | readonly projectId: ProjectId; |
| 101 | } | null; | 101 | }> | null; |
| 102 | readonly searchQuery: string; | 102 | readonly searchQuery: string; |
| 103 | /** Per-row PR state reported up by visible rows ("env:threadId" keys). */ | 103 | /** Per-row PR state reported up by visible rows ("env:threadId" keys). */ |
| 104 | readonly changeRequestStateByKey?: ReadonlyMap<string, "open" | "closed" | "merged">; | 104 | readonly changeRequestStateByKey?: ReadonlyMap<string, "open" | "closed" | "merged">; |
| ... | @@ -115,6 +115,9 @@ export function buildThreadListV2Items(input: { | ... | @@ -115,6 +115,9 @@ export function buildThreadListV2Items(input: { |
| 115 | const now = input.now ?? new Date().toISOString(); | 115 | const now = input.now ?? new Date().toISOString(); |
| 116 | const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; | 116 | const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; |
| 117 | const query = input.searchQuery.trim().toLocaleLowerCase(); | 117 | const query = input.searchQuery.trim().toLocaleLowerCase(); |
| 118 | const projectKeys = input.projectRefs | ||
| 119 | ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) | ||
| 120 | : null; | ||
| 118 | 121 | ||
| 119 | const active: EnvironmentThreadShell[] = []; | 122 | const active: EnvironmentThreadShell[] = []; |
| 120 | const settled: EnvironmentThreadShell[] = []; | 123 | const settled: EnvironmentThreadShell[] = []; |
| ... | @@ -122,11 +125,7 @@ export function buildThreadListV2Items(input: { | ... | @@ -122,11 +125,7 @@ export function buildThreadListV2Items(input: { |
| 122 | // Callers pass live (unarchived) shells; settled threads are among them | 125 | // Callers pass live (unarchived) shells; settled threads are among them |
| 123 | // and partition into the tail via effectiveSettled. | 126 | // and partition into the tail via effectiveSettled. |
| 124 | if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; | 127 | if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; |
| 125 | if ( | 128 | if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) { |
| 126 | input.projectRef != null && | ||
| 127 | (thread.environmentId !== input.projectRef.environmentId || | ||
| 128 | thread.projectId !== input.projectRef.projectId) | ||
| 129 | ) { | ||
| 130 | continue; | 129 | continue; |
| 131 | } | 130 | } |
| 132 | if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; | 131 | if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; |