authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-23 22:57:42+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-23 22:57:42+02:00
logb3e51317e404109ebc41fe1c9e487d5d73367e76
treea640763bf257827fada52255f7d430fb3656accd
parentddd5a46f3942df302f8a13310cdfa47838b0ca81
signature Signed by PGP key B5690EEEBB952194

Fix logical project grouping labels on mobile (#4391)

Co-authored-by: codex <codex@users.noreply.github.com>

9 files changed, 142 insertions(+), 64 deletions(-)

apps/mobile/src/features/home/HomeHeader.tsx-4
...@@ -385,10 +385,6 @@ function IosHomeHeader(props: HomeHeaderProps) {...@@ -385,10 +385,6 @@ function IosHomeHeader(props: HomeHeaderProps) {
385 title="Thread list options"385 title="Thread list options"
386 separateBackground386 separateBackground
387 >387 >
388 <NativeHeaderToolbar.MenuAction onPress={props.onOpenSettings}>
389 <NativeHeaderToolbar.Label>Settings</NativeHeaderToolbar.Label>
390 </NativeHeaderToolbar.MenuAction>
391
392 <NativeHeaderToolbar.Menu title="Environment">388 <NativeHeaderToolbar.Menu title="Environment">
393 <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label>389 <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label>
394 <NativeHeaderToolbar.MenuAction390 <NativeHeaderToolbar.MenuAction
apps/mobile/src/features/home/HomeRouteScreen.tsx+10-11
...@@ -4,7 +4,6 @@ import { useNavigation } from "@react-navigation/native";...@@ -4,7 +4,6 @@ import { useNavigation } from "@react-navigation/native";
4import { useEffect, useMemo, useState } from "react";4import { useEffect, useMemo, useState } from "react";
55
6import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";6import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
7import { scopedProjectKey } from "../../lib/scopedEntities";
8import { useProjects, useThreadShells } from "../../state/entities";7import { useProjects, useThreadShells } from "../../state/entities";
9import { usePendingNewTasks } from "../../state/use-pending-new-tasks";8import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
10import { useWorkspaceState } from "../../state/workspace";9import { useWorkspaceState } from "../../state/workspace";
...@@ -16,6 +15,7 @@ import { AndroidHomeFabLayout } from "./AndroidHomeFab";...@@ -16,6 +15,7 @@ import { AndroidHomeFabLayout } from "./AndroidHomeFab";
16import { HomeScreen } from "./HomeScreen";15import { HomeScreen } from "./HomeScreen";
17import { HomeHeader } from "./HomeHeader";16import { HomeHeader } from "./HomeHeader";
18import { useHomeListOptions } from "./home-list-options";17import { useHomeListOptions } from "./home-list-options";
18import { buildHomeProjectScopes } from "./homeThreadList";
19import { usePendingTaskListActions } from "./usePendingTaskListActions";19import { usePendingTaskListActions } from "./usePendingTaskListActions";
20import { useThreadListActions } from "./useThreadListActions";20import { useThreadListActions } from "./useThreadListActions";
2121
...@@ -61,16 +61,15 @@ export function HomeRouteScreen() {...@@ -61,16 +61,15 @@ export function HomeRouteScreen() {
61 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);61 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
62 const projectFilterOptions = useMemo(62 const projectFilterOptions = useMemo(
63 () =>63 () =>
64 projects64 buildHomeProjectScopes({
65 .filter(65 projects,
66 (project) =>66 environmentId: selectedEnvironmentId,
67 selectedEnvironmentId === null || project.environmentId === selectedEnvironmentId,67 projectGroupingMode: listOptions.projectGroupingMode,
68 )68 }).map((scope) => ({
69 .map((project) => ({69 key: scope.key,
70 key: scopedProjectKey(project.environmentId, project.id),70 label: scope.title,
71 label: project.title,71 })),
72 })),72 [listOptions.projectGroupingMode, projects, selectedEnvironmentId],
73 [projects, selectedEnvironmentId],
74 );73 );
75 useEffect(() => {74 useEffect(() => {
76 if (75 if (
apps/mobile/src/features/home/HomeScreen.tsx+70-29
...@@ -245,43 +245,69 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -245,43 +245,69 @@ export function HomeScreen(props: HomeScreenProps) {
245 onScrollBeginDrag: handleScrollBeginDrag,245 onScrollBeginDrag: handleScrollBeginDrag,
246 });246 });
247247
248 const scopedProject = useMemo(248 const projectScopes = useMemo(
249 () =>
250 buildHomeProjectScopes({
251 projects: props.projects,
252 environmentId: props.selectedEnvironmentId,
253 projectGroupingMode: props.projectGroupingMode,
254 }),
255 [props.projectGroupingMode, props.projects, props.selectedEnvironmentId],
256 );
257 const selectedProjectScope = useMemo(
249 () =>258 () =>
250 props.selectedProjectKey === null259 props.selectedProjectKey === null
251 ? null260 ? null
252 : (props.projects.find(261 : (projectScopes.find(
253 (project) =>262 (scope) =>
254 scopedProjectKey(project.environmentId, project.id) === props.selectedProjectKey &&263 scope.key === props.selectedProjectKey ||
255 (props.selectedEnvironmentId === null ||264 scope.projectRefs.some(
256 project.environmentId === props.selectedEnvironmentId),265 (projectRef) =>
266 scopedProjectKey(projectRef.environmentId, projectRef.projectId) ===
267 props.selectedProjectKey,
268 ),
257 ) ?? null),269 ) ?? null),
258 [props.projects, props.selectedEnvironmentId, props.selectedProjectKey],270 [projectScopes, props.selectedProjectKey],
271 );
272 const selectedProjectRefKeys = useMemo(
273 () =>
274 selectedProjectScope === null
275 ? null
276 : new Set(
277 selectedProjectScope.projectRefs.map((projectRef) =>
278 scopedProjectKey(projectRef.environmentId, projectRef.projectId),
279 ),
280 ),
281 [selectedProjectScope],
259 );282 );
260 const scopedProjects = useMemo(283 const scopedProjects = useMemo(
261 () => (scopedProject === null ? props.projects : [scopedProject]),284 () =>
262 [props.projects, scopedProject],285 selectedProjectRefKeys === null
286 ? props.projects
287 : props.projects.filter((project) =>
288 selectedProjectRefKeys.has(scopedProjectKey(project.environmentId, project.id)),
289 ),
290 [props.projects, selectedProjectRefKeys],
263 );291 );
264 const scopedThreads = useMemo(292 const scopedThreads = useMemo(
265 () =>293 () =>
266 scopedProject === null294 selectedProjectRefKeys === null
267 ? props.threads295 ? props.threads
268 : props.threads.filter(296 : props.threads.filter((thread) =>
269 (thread) =>297 selectedProjectRefKeys.has(scopedProjectKey(thread.environmentId, thread.projectId)),
270 thread.environmentId === scopedProject.environmentId &&
271 thread.projectId === scopedProject.id,
272 ),298 ),
273 [props.threads, scopedProject],299 [props.threads, selectedProjectRefKeys],
274 );300 );
275 const scopedPendingTasks = useMemo(301 const scopedPendingTasks = useMemo(
276 () =>302 () =>
277 scopedProject === null303 selectedProjectRefKeys === null
278 ? props.pendingTasks304 ? props.pendingTasks
279 : props.pendingTasks.filter(305 : props.pendingTasks.filter((pendingTask) =>
280 (pendingTask) =>306 selectedProjectRefKeys.has(
281 pendingTask.message.environmentId === scopedProject.environmentId &&307 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
282 pendingTask.creation.projectId === scopedProject.id,308 ),
283 ),309 ),
284 [props.pendingTasks, scopedProject],310 [props.pendingTasks, selectedProjectRefKeys],
285 );311 );
286312
287 const projectGroups = useMemo(313 const projectGroups = useMemo(
...@@ -339,22 +365,18 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -339,22 +365,18 @@ export function HomeScreen(props: HomeScreenProps) {
339 const v2ScopeProjects = useMemo(365 const v2ScopeProjects = useMemo(
340 () =>366 () =>
341 sortHomeProjectScopes({367 sortHomeProjectScopes({
342 scopes: buildHomeProjectScopes({368 scopes: projectScopes,
343 projects: props.projects,
344 environmentId: props.selectedEnvironmentId,
345 projectGroupingMode: props.projectGroupingMode,
346 }),
347 threads: props.threads,369 threads: props.threads,
348 pendingTasks: props.pendingTasks,370 pendingTasks: props.pendingTasks,
349 projectSortOrder: props.projectSortOrder,371 projectSortOrder: props.projectSortOrder,
350 }),372 }),
351 [373 [
352 props.pendingTasks,374 props.pendingTasks,
353 props.projectGroupingMode,
354 props.projects,375 props.projects,
355 props.projectSortOrder,376 props.projectSortOrder,
356 props.selectedEnvironmentId,377 props.selectedEnvironmentId,
357 props.threads,378 props.threads,
379 projectScopes,
358 ],380 ],
359 );381 );
360 const v2ScopedProjectGroup = useMemo(382 const v2ScopedProjectGroup = useMemo(
...@@ -372,6 +394,21 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -372,6 +394,21 @@ export function HomeScreen(props: HomeScreenProps) {
372 ) ?? null),394 ) ?? null),
373 [v2ProjectScopeKey, v2ScopeProjects],395 [v2ProjectScopeKey, v2ScopeProjects],
374 );396 );
397 const v2ProjectTitleByProjectKey = useMemo(
398 () =>
399 new Map(
400 v2ScopeProjects.flatMap((scope) =>
401 scope.projectRefs.map(
402 (projectRef) =>
403 [
404 scopedProjectKey(projectRef.environmentId, projectRef.projectId),
405 scope.title,
406 ] as const,
407 ),
408 ),
409 ),
410 [v2ScopeProjects],
411 );
375 const v2ScopedProjectKeys = useMemo(412 const v2ScopedProjectKeys = useMemo(
376 () =>413 () =>
377 v2ScopedProjectGroup === null414 v2ScopedProjectGroup === null
...@@ -493,6 +530,9 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -493,6 +530,9 @@ export function HomeScreen(props: HomeScreenProps) {
493 projectByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ??530 projectByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ??
494 null531 null
495 }532 }
533 projectTitle={v2ProjectTitleByProjectKey.get(
534 scopedProjectKey(item.thread.environmentId, item.thread.projectId),
535 )}
496 providerDriver={536 providerDriver={
497 serverConfigs537 serverConfigs
498 .get(item.thread.environmentId)538 .get(item.thread.environmentId)
...@@ -536,6 +576,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -536,6 +576,7 @@ export function HomeScreen(props: HomeScreenProps) {
536 props.savedConnectionsById,576 props.savedConnectionsById,
537 serverConfigs,577 serverConfigs,
538 settlementEnvironmentIds,578 settlementEnvironmentIds,
579 v2ProjectTitleByProjectKey,
539 ],580 ],
540 );581 );
541 const v2KeyExtractor = useCallback(582 const v2KeyExtractor = useCallback(
...@@ -755,9 +796,9 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -755,9 +796,9 @@ export function HomeScreen(props: HomeScreenProps) {
755 const listEmpty = !hasResults ? (796 const listEmpty = !hasResults ? (
756 hasSearchQuery ? (797 hasSearchQuery ? (
757 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />798 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
758 ) : scopedProject !== null ? (799 ) : selectedProjectScope !== null ? (
759 <EmptyState800 <EmptyState
760 title={`No threads in ${scopedProject.title}`}801 title={`No threads in ${selectedProjectScope.title}`}
761 detail="Choose another project or create a new task."802 detail="Choose another project or create a new task."
762 />803 />
763 ) : selectedEnvironmentLabel ? (804 ) : selectedEnvironmentLabel ? (
apps/mobile/src/features/home/home-list-filter-menu.test.ts+1
...@@ -24,6 +24,7 @@ describe("buildHomeListFilterMenu", () => {...@@ -24,6 +24,7 @@ describe("buildHomeListFilterMenu", () => {
24 const projectMenu = menu.items.find(24 const projectMenu = menu.items.find(
25 (item) => item.type === "submenu" && item.title === "Project",25 (item) => item.type === "submenu" && item.title === "Project",
26 );26 );
27 expect(menu.items.some((item) => item.title === "Settings")).toBe(false);
27 expect(projectMenu).toMatchObject({28 expect(projectMenu).toMatchObject({
28 type: "submenu",29 type: "submenu",
29 items: [30 items: [
apps/mobile/src/features/home/home-list-filter-menu.ts-9
...@@ -43,7 +43,6 @@ export function buildHomeListFilterMenu(props: {...@@ -43,7 +43,6 @@ export function buildHomeListFilterMenu(props: {
43 readonly onProjectChange: (projectKey: string | null) => void;43 readonly onProjectChange: (projectKey: string | null) => void;
44 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;44 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
45 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;45 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
46 readonly onOpenSettings?: () => void;
47 /** False hides the sort/group submenus. Thread List v2 uses a fixed46 /** False hides the sort/group submenus. Thread List v2 uses a fixed
48 creation-order layout, so offering those controls while it silently47 creation-order layout, so offering those controls while it silently
49 ignores them would be a lie; the environment filter still applies. */48 ignores them would be a lie; the environment filter still applies. */
...@@ -51,14 +50,6 @@ export function buildHomeListFilterMenu(props: {...@@ -51,14 +50,6 @@ export function buildHomeListFilterMenu(props: {
51}): HomeListFilterMenu {50}): HomeListFilterMenu {
52 const items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu> = [];51 const items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu> = [];
5352
54 if (props.onOpenSettings) {
55 items.push({
56 type: "action",
57 title: "Settings",
58 onPress: props.onOpenSettings,
59 });
60 }
61
62 items.push({53 items.push({
63 type: "submenu",54 type: "submenu",
64 title: "Environment",55 title: "Environment",
apps/mobile/src/features/home/homeThreadList.test.ts+38
...@@ -104,6 +104,7 @@ describe("buildHomeThreadGroups", () => {...@@ -104,6 +104,7 @@ describe("buildHomeThreadGroups", () => {
104 });104 });
105105
106 expect(scopes).toHaveLength(1);106 expect(scopes).toHaveLength(1);
107 expect(scopes[0]?.title).toBe("t3code");
107 expect(scopes[0]?.projects).toEqual(projects);108 expect(scopes[0]?.projects).toEqual(projects);
108 expect(scopes[0]?.projectRefs).toEqual(109 expect(scopes[0]?.projectRefs).toEqual(
109 projects.map((project) => ({110 projects.map((project) => ({
...@@ -373,6 +374,43 @@ describe("buildHomeThreadGroups", () => {...@@ -373,6 +374,43 @@ describe("buildHomeThreadGroups", () => {
373 ).toHaveLength(2);374 ).toHaveLength(2);
374 });375 });
375376
377 it("uses the repository label for a singleton repository scope", () => {
378 const project = makeProject({
379 environmentId: EnvironmentId.make("environment-1"),
380 id: ProjectId.make("project-1"),
381 title: "local-worktree-name",
382 repositoryIdentity: {
383 canonicalKey: "github.com/pingdotgg/t3code",
384 displayName: "codething-mvp",
385 locator: {
386 source: "git-remote" as const,
387 remoteName: "origin",
388 remoteUrl: "git@github.com:pingdotgg/t3code.git",
389 },
390 },
391 });
392
393 const scopes = buildHomeProjectScopes({
394 projects: [project],
395 environmentId: null,
396 projectGroupingMode: "repository",
397 });
398 const groups = buildGroups(
399 [project],
400 [
401 makeThread({
402 environmentId: project.environmentId,
403 id: ThreadId.make("thread-1"),
404 projectId: project.id,
405 title: "Thread",
406 }),
407 ],
408 );
409
410 expect(scopes[0]?.title).toBe("codething-mvp");
411 expect(groups[0]?.title).toBe("codething-mvp");
412 });
413
376 it("sorts the newest thread first regardless of snapshot order", () => {414 it("sorts the newest thread first regardless of snapshot order", () => {
377 const environmentId = EnvironmentId.make("environment-1");415 const environmentId = EnvironmentId.make("environment-1");
378 const project = makeProject({416 const project = makeProject({
apps/mobile/src/features/home/homeThreadList.ts+2-8
...@@ -113,10 +113,7 @@ export function buildHomeProjectScopes(input: {...@@ -113,10 +113,7 @@ export function buildHomeProjectScopes(input: {
113 const representative = projects[0]!;113 const representative = projects[0]!;
114 return {114 return {
115 key,115 key,
116 title:116 title: deriveProjectGroupLabel({ representative, members: projects }),
117 projects.length > 1
118 ? deriveProjectGroupLabel({ representative, members: projects })
119 : representative.title,
120 representative,117 representative,
121 projects,118 projects,
122 projectRefs: projectRefsByGroup.get(key) ?? [],119 projectRefs: projectRefsByGroup.get(key) ?? [],
...@@ -346,10 +343,7 @@ export function buildHomeThreadGroups(input: {...@@ -346,10 +343,7 @@ export function buildHomeThreadGroups(input: {
346 continue;343 continue;
347 }344 }
348345
349 const title =346 const title = deriveProjectGroupLabel({ representative, members: group.projects });
350 group.projects.length > 1
351 ? deriveProjectGroupLabel({ representative, members: group.projects })
352 : representative.title;
353 const groupMatches =347 const groupMatches =
354 query.length === 0 ||348 query.length === 0 ||
355 title.toLocaleLowerCase().includes(query) ||349 title.toLocaleLowerCase().includes(query) ||
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+17
...@@ -236,6 +236,21 @@ function ThreadNavigationSidebarPane(...@@ -236,6 +236,21 @@ function ThreadNavigationSidebarPane(
236 })),236 })),
237 [projectScopes],237 [projectScopes],
238 );238 );
239 const projectTitleByProjectKey = useMemo(
240 () =>
241 new Map(
242 projectScopes.flatMap((scope) =>
243 scope.projectRefs.map(
244 (projectRef) =>
245 [
246 scopedProjectKey(projectRef.environmentId, projectRef.projectId),
247 scope.title,
248 ] as const,
249 ),
250 ),
251 ),
252 [projectScopes],
253 );
239 const selectedProjectScope = useMemo(254 const selectedProjectScope = useMemo(
240 () =>255 () =>
241 selectedProjectKey === null256 selectedProjectKey === null
...@@ -728,6 +743,7 @@ function ThreadNavigationSidebarPane(...@@ -728,6 +743,7 @@ function ThreadNavigationSidebarPane(
728 variant={item.item.variant}743 variant={item.item.variant}
729 showSettledDivider={item.item.showSettledDivider}744 showSettledDivider={item.item.showSettledDivider}
730 project={projectByKey.get(scopeKey) ?? null}745 project={projectByKey.get(scopeKey) ?? null}
746 projectTitle={projectTitleByProjectKey.get(scopeKey)}
731 providerDriver={747 providerDriver={
732 serverConfigs748 serverConfigs
733 .get(thread.environmentId)749 .get(thread.environmentId)
...@@ -857,6 +873,7 @@ function ThreadNavigationSidebarPane(...@@ -857,6 +873,7 @@ function ThreadNavigationSidebarPane(
857 openPendingTask,873 openPendingTask,
858 projectByKey,874 projectByKey,
859 projectCwdByKey,875 projectCwdByKey,
876 projectTitleByProjectKey,
860 props.onNewThreadInProject,877 props.onNewThreadInProject,
861 props.selectedThreadKey,878 props.selectedThreadKey,
862 props.width,879 props.width,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+4-3
...@@ -90,6 +90,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -90,6 +90,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
90 readonly variant: "card" | "slim";90 readonly variant: "card" | "slim";
91 readonly showSettledDivider: boolean;91 readonly showSettledDivider: boolean;
92 readonly project: EnvironmentProject | null;92 readonly project: EnvironmentProject | null;
93 readonly projectTitle?: string;
93 readonly providerDriver: string | null;94 readonly providerDriver: string | null;
94 /** Which machine hosts the thread. Null when only one environment is95 /** Which machine hosts the thread. Null when only one environment is
95 connected — repeating the same label on every row is noise. Mirrors96 connected — repeating the same label on every row is noise. Mirrors
...@@ -219,7 +220,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -219,7 +220,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
219 <ProjectFavicon220 <ProjectFavicon
220 environmentId={thread.environmentId}221 environmentId={thread.environmentId}
221 size={15}222 size={15}
222 projectTitle={props.project.title}223 projectTitle={props.projectTitle ?? props.project.title}
223 workspaceRoot={props.project.workspaceRoot}224 workspaceRoot={props.project.workspaceRoot}
224 />225 />
225 ) : null}226 ) : null}
...@@ -230,7 +231,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -230,7 +231,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
230 )}231 )}
231 numberOfLines={1}232 numberOfLines={1}
232 >233 >
233 {props.project?.title ?? ""}234 {props.projectTitle ?? props.project?.title ?? ""}
234 </Text>235 </Text>
235 <Text236 <Text
236 className={cn(237 className={cn(
...@@ -393,7 +394,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -393,7 +394,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
393 <ProjectFavicon394 <ProjectFavicon
394 environmentId={thread.environmentId}395 environmentId={thread.environmentId}
395 size={15}396 size={15}
396 projectTitle={props.project.title}397 projectTitle={props.projectTitle ?? props.project.title}
397 workspaceRoot={props.project.workspaceRoot}398 workspaceRoot={props.project.workspaceRoot}
398 />399 />
399 </View>400 </View>