From a2256957beecdd4903550b64d17f6e9377bd18c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Wi=C5=9Bniewski?= Date: Sat, 19 Sep 2026 15:04:11 +0200 Subject: [PATCH] feat(mobile): remove projects from the project picker Mobile had no durable way to remove a project after it was added. The only delete path was the temporary clone banner, so a mis-added project remained in the picker unless the user switched clients.\n\nAdd one focused removal entry point to Choose project: swipe a row on iOS or long-press it on Android, then confirm with the path, environment, and affected thread count. Removal uses the existing forced project delete so associated threads are removed while files on disk stay untouched.\n\nMemoized iOS rows keep an open swipe stable while thread updates re-render the picker, and the swipe finger-up cannot fall through into project selection.\n\nGPT-5.6 Sol via Codex in T3 Code. --- .../features/home/thread-swipe-actions.tsx | 3 +- .../features/projects/remove-project.test.ts | 57 ++++ .../src/features/projects/remove-project.ts | 44 +++ .../projects/useConfirmRemoveProjects.ts | 97 +++++++ .../features/threads/NewTaskRouteScreen.tsx | 273 ++++++++++++++---- 5 files changed, 411 insertions(+), 63 deletions(-) create mode 100644 apps/mobile/src/features/projects/remove-project.test.ts create mode 100644 apps/mobile/src/features/projects/remove-project.ts create mode 100644 apps/mobile/src/features/projects/useConfirmRemoveProjects.ts diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 44f3c36e6655..22d874a6c645 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -67,6 +67,7 @@ interface ThreadSwipeAction { readonly title?: string; }; readonly onPress: () => void; + readonly tone?: "primary" | "secondary" | "danger"; } interface ThreadSwipeSecondaryAction extends ThreadSwipeAction { @@ -712,7 +713,7 @@ export function ThreadSwipeActions(props: { ({ + environmentId: EnvironmentId.make(`environment-${id}`), + id: ProjectId.make(`project-${id}`), + title: `Project ${id}`, + workspaceRoot: `/home/user/${id}`, + ...overrides, +}); + +describe("buildRemoveProjectsConfirmation", () => { + it("describes a single project with its path, environment, and thread count", () => { + const confirmation = buildRemoveProjectsConfirmation({ + members: [member("a", { environmentLabel: "Mac mini" })], + groupTitle: "Group", + threadCount: 3, + }); + + expect(confirmation.title).toBe("Remove project “Project a”?"); + expect(confirmation.confirmText).toBe("Remove"); + expect(confirmation.message).toBe( + [ + "This deletes its 3 threads and permanently clears their conversation history, including archived threads.", + "Path: /home/user/a", + "Environment: Mac mini", + "Only the project entry is removed. Files on disk are not touched.", + "This action cannot be undone.", + ].join("\n"), + ); + }); + + it("uses singular thread copy and skips the environment line when unknown", () => { + const confirmation = buildRemoveProjectsConfirmation({ + members: [member("a")], + groupTitle: "Group", + threadCount: 1, + }); + + expect(confirmation.message).toContain("This deletes its 1 thread and"); + expect(confirmation.message).not.toContain("Environment:"); + }); + + it("names the group and counts entries when several checkouts go at once", () => { + const confirmation = buildRemoveProjectsConfirmation({ + members: [member("a"), member("b")], + groupTitle: "shared-repo", + threadCount: 0, + }); + + expect(confirmation.title).toBe("Remove project “shared-repo”?"); + expect(confirmation.message).toContain("This removes 2 grouped project entries."); + expect(confirmation.message).not.toContain("Path:"); + }); +}); diff --git a/apps/mobile/src/features/projects/remove-project.ts b/apps/mobile/src/features/projects/remove-project.ts new file mode 100644 index 000000000000..428bdce683fa --- /dev/null +++ b/apps/mobile/src/features/projects/remove-project.ts @@ -0,0 +1,44 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; + +export interface RemoveProjectsConfirmation { + readonly title: string; + readonly message: string; + readonly confirmText: string; +} + +export interface RemoveProjectsMember extends Pick< + EnvironmentProject, + "environmentId" | "id" | "title" | "workspaceRoot" +> { + readonly environmentLabel?: string | null; +} + +/** Copy for the destructive confirmation before removing a grouped project. */ +export function buildRemoveProjectsConfirmation(input: { + readonly members: ReadonlyArray; + readonly groupTitle: string; + readonly threadCount: number; +}): RemoveProjectsConfirmation { + const singleMember = input.members.length === 1 ? input.members[0]! : null; + const targetLabel = singleMember?.title ?? input.groupTitle; + const lines = [ + input.threadCount > 0 + ? `This deletes its ${input.threadCount} thread${input.threadCount === 1 ? "" : "s"} and permanently clears their conversation history, including archived threads.` + : "This permanently clears any archived conversation history.", + ...(singleMember + ? [ + `Path: ${singleMember.workspaceRoot}`, + ...(singleMember.environmentLabel + ? [`Environment: ${singleMember.environmentLabel}`] + : []), + ] + : [`This removes ${input.members.length} grouped project entries.`]), + "Only the project entry is removed. Files on disk are not touched.", + "This action cannot be undone.", + ]; + return { + title: `Remove project “${targetLabel}”?`, + message: lines.join("\n"), + confirmText: "Remove", + }; +} diff --git a/apps/mobile/src/features/projects/useConfirmRemoveProjects.ts b/apps/mobile/src/features/projects/useConfirmRemoveProjects.ts new file mode 100644 index 000000000000..88d52b46ef35 --- /dev/null +++ b/apps/mobile/src/features/projects/useConfirmRemoveProjects.ts @@ -0,0 +1,97 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useCallback } from "react"; +import { Alert } from "react-native"; + +import { showConfirmDialog } from "../../components/ConfirmDialogHost"; +import { scopedProjectKey } from "../../lib/scopedEntities"; +import { useThreadShells } from "../../state/entities"; +import { projectEnvironment } from "../../state/projects"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; +import { buildRemoveProjectsConfirmation, type RemoveProjectsConfirmation } from "./remove-project"; + +function confirmRemoval(confirmation: RemoveProjectsConfirmation): Promise { + return new Promise((resolve) => { + if (process.env.EXPO_OS === "ios") { + Alert.alert( + confirmation.title, + confirmation.message, + [ + { text: "Cancel", style: "cancel", onPress: () => resolve(false) }, + { text: confirmation.confirmText, style: "destructive", onPress: () => resolve(true) }, + ], + { onDismiss: () => resolve(false) }, + ); + return; + } + showConfirmDialog({ + title: confirmation.title, + message: confirmation.message, + confirmText: confirmation.confirmText, + destructive: true, + onConfirm: () => resolve(true), + onCancel: () => resolve(false), + }); + }); +} + +/** + * Confirms and removes project entries from their environments. The server + * deletes the projects' threads along the way (`force`), so the confirmation + * spells out how many threads go with them. Every confirmed member is + * attempted even if one fails, so a transient failure does not strand later + * entries. Resolves true once every member is gone. + */ +export function useConfirmRemoveProjects(): ( + members: ReadonlyArray, + options: { readonly groupTitle: string }, +) => Promise { + const threads = useThreadShells(); + const { savedConnectionsById } = useSavedRemoteConnections(); + const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); + + return useCallback( + async (members, options) => { + if (members.length === 0) return false; + const memberKeys = new Set( + members.map((member) => scopedProjectKey(member.environmentId, member.id)), + ); + const threadCount = threads.filter((thread) => + memberKeys.has(scopedProjectKey(thread.environmentId, thread.projectId)), + ).length; + const confirmed = await confirmRemoval( + buildRemoveProjectsConfirmation({ + members: members.map((member) => ({ + ...member, + environmentLabel: savedConnectionsById[member.environmentId]?.environmentLabel ?? null, + })), + groupTitle: options.groupTitle, + threadCount, + }), + ); + if (!confirmed) return false; + + const failures: string[] = []; + for (const member of members) { + const result = await deleteProject({ + environmentId: member.environmentId, + input: { projectId: member.id, force: true }, + }); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + failures.push( + `${member.workspaceRoot}: ${error instanceof Error ? error.message : "An error occurred."}`, + ); + } + } + if (failures.length > 0) { + Alert.alert("Some project entries could not be removed", failures.join("\n")); + return false; + } + return true; + }, + [deleteProject, savedConnectionsById, threads], + ); +} diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index f74542076be2..3c1f319f9eaf 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -8,10 +8,23 @@ import { } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import { useEffect, useRef, useState } from "react"; -import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; +import type { MenuAction } from "@react-native-menu/menu"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ActivityIndicator, + Alert, + type ColorValue, + Platform, + Pressable, + useWindowDimensions, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { cn } from "../../lib/cn"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { ThreadSwipeable } from "../home/thread-swipe-actions"; +import { useConfirmRemoveProjects } from "../projects/useConfirmRemoveProjects"; import { MaterialScreenContent } from "../../components/MaterialScreenContent"; import { MaterialButton } from "../../components/MaterialButton"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; @@ -125,10 +138,161 @@ function NewTaskHeader(props: { ); } +const PROJECT_ROW_MENU_ACTIONS: MenuAction[] = [ + { + id: "remove-project", + title: "Remove project", + image: "trash", + attributes: { destructive: true }, + }, +]; + +/** Swipe left for Remove; a tap on an open row only closes its actions. */ +/** Presses arriving this soon after a swipe drag are the drag's own finger-up. */ +const SWIPE_PRESS_GRACE_MS = 600; + +const SwipeableProjectRow = memo(function SwipeableProjectRow(props: { + // Primitive props only: the picker re-renders on every thread update while + // an agent works, and an open swipe must survive that untouched. + readonly scopeKey: string; + readonly title: string; + readonly subtitle: string; + readonly environmentId: EnvironmentProject["environmentId"]; + readonly faviconPath: EnvironmentProject["faviconPath"]; + readonly workspaceRoot: string; + readonly isFirst: boolean; + readonly disabled: boolean; + readonly backgroundColor: ColorValue; + readonly fullSwipeWidth: number; + readonly onSelect: (scopeKey: string) => void; + readonly onRemove: (scopeKey: string) => void; +}) { + const { disabled, scopeKey, title, onRemove, onSelect } = props; + // Lifting the finger at the end of a swipe still reaches the Pressable as a + // press (the touch never left the row), so presses right after a drag are + // dropped; only a later tap on an open row closes it. + const swipeOpenRef = useRef(false); + const lastSwipeAtRef = useRef(0); + const markSwipedOpen = useCallback(() => { + swipeOpenRef.current = true; + lastSwipeAtRef.current = Date.now(); + }, []); + const markSwipedClosed = useCallback(() => { + swipeOpenRef.current = false; + lastSwipeAtRef.current = Date.now(); + }, []); + const remove = useCallback(() => { + if (!disabled) onRemove(scopeKey); + }, [disabled, onRemove, scopeKey]); + const removeAction = useMemo( + () => ({ + accessibilityLabel: `Remove project ${title}`, + icon: "trash" as const, + label: "Remove", + tone: "danger" as const, + onPress: remove, + }), + [remove, title], + ); + return ( + + {(close) => ( + + { + if (Date.now() - lastSwipeAtRef.current < SWIPE_PRESS_GRACE_MS) return; + if (swipeOpenRef.current) { + close(); + return; + } + onSelect(scopeKey); + }} + className="bg-card" + > + + + + + + {title} + + {props.subtitle} + + + + + + + )} + + ); +}); + export function NewTaskRouteScreen({ route }: StaticScreenProps) { const projects = useProjects(); const [searchText, setSearchText] = useState(""); const { projectScopes, selectedEnvironmentId, setProject } = useNewTaskFlow(); + const { width: windowWidth } = useWindowDimensions(); + const cardColor = useAppearancePreferences().themeVariables["--color-card"]; + // This picker is the one place mobile lists every project, so it doubles + // as the place to remove one: swipe on iOS, long-press on Android. + const confirmRemoveProjects = useConfirmRemoveProjects(); + const removeScope = (scope: (typeof projectScopes)[number]) => { + void confirmRemoveProjects(scope.projects, { groupTitle: scope.title }); + }; + // The memoized iOS rows receive stable callbacks that resolve the current + // scope by key at press time instead of closing over per-render objects. + const latest = { projectScopes, removeScope, selectProject, selectedEnvironmentId }; + const latestRef = useRef(latest); + useEffect(() => { + latestRef.current = latest; + }); + const [rowActions] = useState(() => ({ + remove(scopeKey: string) { + const scope = latestRef.current.projectScopes.find((entry) => entry.key === scopeKey); + if (scope) latestRef.current.removeScope(scope); + }, + select(scopeKey: string) { + const scope = latestRef.current.projectScopes.find((entry) => entry.key === scopeKey); + if (scope) { + void latestRef.current.selectProject( + getProjectScopeSelectionTarget(scope, latestRef.current.selectedEnvironmentId), + ); + } + }, + })); const { state: catalogState } = useWorkspaceState(); const navigation = useNavigation(); const isFocused = useIsFocused(); @@ -319,71 +483,56 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps void selectProject(selectionTarget)} - leading={ - - } - /> + actions={PROJECT_ROW_MENU_ACTIONS} + onPressAction={({ nativeEvent }) => { + if (nativeEvent.event === "remove-project") removeScope(scope); + }} + shouldOpenOnLongPress + > + void selectProject(selectionTarget)} + leading={ + + } + /> + ); } return ( - 0 && "border-t border-border-subtle")} - > - void selectProject(selectionTarget)} - className="flex-row items-center gap-3 bg-card px-4 py-3.5" - > - - - - - - {scope.title} - - - {hasMultipleProjects - ? `${scope.projects.length} workspaces` - : selectionTarget.workspaceRoot} - - - - - + backgroundColor={cardColor} + disabled={reservedDestinationProject !== null} + environmentId={scope.representative.environmentId} + faviconPath={scope.representative.faviconPath} + fullSwipeWidth={windowWidth - 40} + isFirst={scopeIndex === 0} + onRemove={rowActions.remove} + onSelect={rowActions.select} + scopeKey={scope.key} + subtitle={ + hasMultipleProjects + ? `${scope.projects.length} workspaces` + : selectionTarget.workspaceRoot + } + title={scope.title} + workspaceRoot={scope.representative.workspaceRoot} + /> ); })}