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