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) {
385385 title="Thread list options"
386386 separateBackground
387387 >
388 <NativeHeaderToolbar.MenuAction onPress={props.onOpenSettings}>
389 <NativeHeaderToolbar.Label>Settings</NativeHeaderToolbar.Label>
390 </NativeHeaderToolbar.MenuAction>
391
392388 <NativeHeaderToolbar.Menu title="Environment">
393389 <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label>
394390 <NativeHeaderToolbar.MenuAction
apps/mobile/src/features/home/HomeRouteScreen.tsx+10-11
......@@ -4,7 +4,6 @@ import { useNavigation } from "@react-navigation/native";
44import { useEffect, useMemo, useState } from "react";
55
66import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
7import { scopedProjectKey } from "../../lib/scopedEntities";
87import { useProjects, useThreadShells } from "../../state/entities";
98import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
109import { useWorkspaceState } from "../../state/workspace";
......@@ -16,6 +15,7 @@ import { AndroidHomeFabLayout } from "./AndroidHomeFab";
1615import { HomeScreen } from "./HomeScreen";
1716import { HomeHeader } from "./HomeHeader";
1817import { useHomeListOptions } from "./home-list-options";
18import { buildHomeProjectScopes } from "./homeThreadList";
1919import { usePendingTaskListActions } from "./usePendingTaskListActions";
2020import { useThreadListActions } from "./useThreadListActions";
2121
......@@ -61,16 +61,15 @@ export function HomeRouteScreen() {
6161 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
6262 const projectFilterOptions = useMemo(
6363 () =>
64 projects
65 .filter(
66 (project) =>
67 selectedEnvironmentId === null || project.environmentId === selectedEnvironmentId,
68 )
69 .map((project) => ({
70 key: scopedProjectKey(project.environmentId, project.id),
71 label: project.title,
72 })),
73 [projects, selectedEnvironmentId],
64 buildHomeProjectScopes({
65 projects,
66 environmentId: selectedEnvironmentId,
67 projectGroupingMode: listOptions.projectGroupingMode,
68 }).map((scope) => ({
69 key: scope.key,
70 label: scope.title,
71 })),
72 [listOptions.projectGroupingMode, projects, selectedEnvironmentId],
7473 );
7574 useEffect(() => {
7675 if (
apps/mobile/src/features/home/HomeScreen.tsx+70-29
......@@ -245,43 +245,69 @@ export function HomeScreen(props: HomeScreenProps) {
245245 onScrollBeginDrag: handleScrollBeginDrag,
246246 });
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(
249258 () =>
250259 props.selectedProjectKey === null
251260 ? null
252 : (props.projects.find(
253 (project) =>
254 scopedProjectKey(project.environmentId, project.id) === props.selectedProjectKey &&
255 (props.selectedEnvironmentId === null ||
256 project.environmentId === props.selectedEnvironmentId),
261 : (projectScopes.find(
262 (scope) =>
263 scope.key === props.selectedProjectKey ||
264 scope.projectRefs.some(
265 (projectRef) =>
266 scopedProjectKey(projectRef.environmentId, projectRef.projectId) ===
267 props.selectedProjectKey,
268 ),
257269 ) ?? 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],
259282 );
260283 const scopedProjects = useMemo(
261 () => (scopedProject === null ? props.projects : [scopedProject]),
262 [props.projects, scopedProject],
284 () =>
285 selectedProjectRefKeys === null
286 ? props.projects
287 : props.projects.filter((project) =>
288 selectedProjectRefKeys.has(scopedProjectKey(project.environmentId, project.id)),
289 ),
290 [props.projects, selectedProjectRefKeys],
263291 );
264292 const scopedThreads = useMemo(
265293 () =>
266 scopedProject === null
294 selectedProjectRefKeys === null
267295 ? props.threads
268 : props.threads.filter(
269 (thread) =>
270 thread.environmentId === scopedProject.environmentId &&
271 thread.projectId === scopedProject.id,
296 : props.threads.filter((thread) =>
297 selectedProjectRefKeys.has(scopedProjectKey(thread.environmentId, thread.projectId)),
272298 ),
273 [props.threads, scopedProject],
299 [props.threads, selectedProjectRefKeys],
274300 );
275301 const scopedPendingTasks = useMemo(
276302 () =>
277 scopedProject === null
303 selectedProjectRefKeys === null
278304 ? props.pendingTasks
279 : props.pendingTasks.filter(
280 (pendingTask) =>
281 pendingTask.message.environmentId === scopedProject.environmentId &&
282 pendingTask.creation.projectId === scopedProject.id,
305 : props.pendingTasks.filter((pendingTask) =>
306 selectedProjectRefKeys.has(
307 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
308 ),
283309 ),
284 [props.pendingTasks, scopedProject],
310 [props.pendingTasks, selectedProjectRefKeys],
285311 );
286312
287313 const projectGroups = useMemo(
......@@ -339,22 +365,18 @@ export function HomeScreen(props: HomeScreenProps) {
339365 const v2ScopeProjects = useMemo(
340366 () =>
341367 sortHomeProjectScopes({
342 scopes: buildHomeProjectScopes({
343 projects: props.projects,
344 environmentId: props.selectedEnvironmentId,
345 projectGroupingMode: props.projectGroupingMode,
346 }),
368 scopes: projectScopes,
347369 threads: props.threads,
348370 pendingTasks: props.pendingTasks,
349371 projectSortOrder: props.projectSortOrder,
350372 }),
351373 [
352374 props.pendingTasks,
353 props.projectGroupingMode,
354375 props.projects,
355376 props.projectSortOrder,
356377 props.selectedEnvironmentId,
357378 props.threads,
379 projectScopes,
358380 ],
359381 );
360382 const v2ScopedProjectGroup = useMemo(
......@@ -372,6 +394,21 @@ export function HomeScreen(props: HomeScreenProps) {
372394 ) ?? null),
373395 [v2ProjectScopeKey, v2ScopeProjects],
374396 );
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 );
375412 const v2ScopedProjectKeys = useMemo(
376413 () =>
377414 v2ScopedProjectGroup === null
......@@ -493,6 +530,9 @@ export function HomeScreen(props: HomeScreenProps) {
493530 projectByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ??
494531 null
495532 }
533 projectTitle={v2ProjectTitleByProjectKey.get(
534 scopedProjectKey(item.thread.environmentId, item.thread.projectId),
535 )}
496536 providerDriver={
497537 serverConfigs
498538 .get(item.thread.environmentId)
......@@ -536,6 +576,7 @@ export function HomeScreen(props: HomeScreenProps) {
536576 props.savedConnectionsById,
537577 serverConfigs,
538578 settlementEnvironmentIds,
579 v2ProjectTitleByProjectKey,
539580 ],
540581 );
541582 const v2KeyExtractor = useCallback(
......@@ -755,9 +796,9 @@ export function HomeScreen(props: HomeScreenProps) {
755796 const listEmpty = !hasResults ? (
756797 hasSearchQuery ? (
757798 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
758 ) : scopedProject !== null ? (
799 ) : selectedProjectScope !== null ? (
759800 <EmptyState
760 title={`No threads in ${scopedProject.title}`}
801 title={`No threads in ${selectedProjectScope.title}`}
761802 detail="Choose another project or create a new task."
762803 />
763804 ) : selectedEnvironmentLabel ? (
apps/mobile/src/features/home/home-list-filter-menu.test.ts+1
......@@ -24,6 +24,7 @@ describe("buildHomeListFilterMenu", () => {
2424 const projectMenu = menu.items.find(
2525 (item) => item.type === "submenu" && item.title === "Project",
2626 );
27 expect(menu.items.some((item) => item.title === "Settings")).toBe(false);
2728 expect(projectMenu).toMatchObject({
2829 type: "submenu",
2930 items: [
apps/mobile/src/features/home/home-list-filter-menu.ts-9
......@@ -43,7 +43,6 @@ export function buildHomeListFilterMenu(props: {
4343 readonly onProjectChange: (projectKey: string | null) => void;
4444 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
4545 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
46 readonly onOpenSettings?: () => void;
4746 /** False hides the sort/group submenus. Thread List v2 uses a fixed
4847 creation-order layout, so offering those controls while it silently
4948 ignores them would be a lie; the environment filter still applies. */
......@@ -51,14 +50,6 @@ export function buildHomeListFilterMenu(props: {
5150}): HomeListFilterMenu {
5251 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
6253 items.push({
6354 type: "submenu",
6455 title: "Environment",
apps/mobile/src/features/home/homeThreadList.test.ts+38
......@@ -104,6 +104,7 @@ describe("buildHomeThreadGroups", () => {
104104 });
105105
106106 expect(scopes).toHaveLength(1);
107 expect(scopes[0]?.title).toBe("t3code");
107108 expect(scopes[0]?.projects).toEqual(projects);
108109 expect(scopes[0]?.projectRefs).toEqual(
109110 projects.map((project) => ({
......@@ -373,6 +374,43 @@ describe("buildHomeThreadGroups", () => {
373374 ).toHaveLength(2);
374375 });
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
376414 it("sorts the newest thread first regardless of snapshot order", () => {
377415 const environmentId = EnvironmentId.make("environment-1");
378416 const project = makeProject({
apps/mobile/src/features/home/homeThreadList.ts+2-8
......@@ -113,10 +113,7 @@ export function buildHomeProjectScopes(input: {
113113 const representative = projects[0]!;
114114 return {
115115 key,
116 title:
117 projects.length > 1
118 ? deriveProjectGroupLabel({ representative, members: projects })
119 : representative.title,
116 title: deriveProjectGroupLabel({ representative, members: projects }),
120117 representative,
121118 projects,
122119 projectRefs: projectRefsByGroup.get(key) ?? [],
......@@ -346,10 +343,7 @@ export function buildHomeThreadGroups(input: {
346343 continue;
347344 }
348345
349 const title =
350 group.projects.length > 1
351 ? deriveProjectGroupLabel({ representative, members: group.projects })
352 : representative.title;
346 const title = deriveProjectGroupLabel({ representative, members: group.projects });
353347 const groupMatches =
354348 query.length === 0 ||
355349 title.toLocaleLowerCase().includes(query) ||
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+17
......@@ -236,6 +236,21 @@ function ThreadNavigationSidebarPane(
236236 })),
237237 [projectScopes],
238238 );
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 );
239254 const selectedProjectScope = useMemo(
240255 () =>
241256 selectedProjectKey === null
......@@ -728,6 +743,7 @@ function ThreadNavigationSidebarPane(
728743 variant={item.item.variant}
729744 showSettledDivider={item.item.showSettledDivider}
730745 project={projectByKey.get(scopeKey) ?? null}
746 projectTitle={projectTitleByProjectKey.get(scopeKey)}
731747 providerDriver={
732748 serverConfigs
733749 .get(thread.environmentId)
......@@ -857,6 +873,7 @@ function ThreadNavigationSidebarPane(
857873 openPendingTask,
858874 projectByKey,
859875 projectCwdByKey,
876 projectTitleByProjectKey,
860877 props.onNewThreadInProject,
861878 props.selectedThreadKey,
862879 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: {
9090 readonly variant: "card" | "slim";
9191 readonly showSettledDivider: boolean;
9292 readonly project: EnvironmentProject | null;
93 readonly projectTitle?: string;
9394 readonly providerDriver: string | null;
9495 /** Which machine hosts the thread. Null when only one environment is
9596 connected — repeating the same label on every row is noise. Mirrors
......@@ -219,7 +220,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
219220 <ProjectFavicon
220221 environmentId={thread.environmentId}
221222 size={15}
222 projectTitle={props.project.title}
223 projectTitle={props.projectTitle ?? props.project.title}
223224 workspaceRoot={props.project.workspaceRoot}
224225 />
225226 ) : null}
......@@ -230,7 +231,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
230231 )}
231232 numberOfLines={1}
232233 >
233 {props.project?.title ?? ""}
234 {props.projectTitle ?? props.project?.title ?? ""}
234235 </Text>
235236 <Text
236237 className={cn(
......@@ -393,7 +394,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
393394 <ProjectFavicon
394395 environmentId={thread.environmentId}
395396 size={15}
396 projectTitle={props.project.title}
397 projectTitle={props.projectTitle ?? props.project.title}
397398 workspaceRoot={props.project.workspaceRoot}
398399 />
399400 </View>