| 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 | 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 { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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 | ) : ( |
apps/mobile/src/features/home/homeThreadList.test.ts+307-1| ... | ... | @@ -5,7 +5,11 @@ import type { |
| 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( |
| 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({ |
apps/mobile/src/features/home/homeThreadList.ts+187-25| ... | ... | @@ -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"; |
| 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: { |
| 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: { |
| 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 |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+55-45| ... | ... | @@ -48,7 +48,7 @@ import { |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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 | ); |
apps/mobile/src/features/threads/threadListV2.test.ts+24-1| ... | ... | @@ -166,13 +166,36 @@ describe("buildThreadListV2Items", () => { |
| 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", () => { |
apps/mobile/src/features/threads/threadListV2.ts+6-7| ... | ... | @@ -95,10 +95,10 @@ export interface ThreadListV2Layout { |
| 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: { |
| 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: { |
| 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; |