diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index 521c5e36c32f33bd6272fd893fde413afed6aaed..d7db39d39ae19f3feabaa90c78e3d2feaacd249b 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { Modal, Pressable, View } from "react-native"; +import { Modal, Pressable, TextInput, View } from "react-native"; import { cn } from "../lib/cn"; import { AppText } from "./AppText"; @@ -14,7 +14,20 @@ export type ConfirmDialogRequest = { readonly onCancel?: () => void; }; -let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null; +export type TextInputDialogRequest = { + readonly title: string; + readonly initialValue: string; + readonly cancelText?: string; + readonly confirmText: string; + readonly onConfirm: (value: string) => void; + readonly onCancel?: () => void; +}; + +type DialogRequest = + | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest } + | { readonly kind: "text-input"; readonly request: TextInputDialogRequest }; + +let presentRequest: ((request: DialogRequest) => void) | null = null; /** * Imperative confirm dialog, Alert.alert-shaped. Native iOS alerts already @@ -23,7 +36,11 @@ let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null; * once. Requires ConfirmDialogHost to be mounted at the app root. */ export function showConfirmDialog(request: ConfirmDialogRequest): void { - presentRequest?.(request); + presentRequest?.({ kind: "confirm", request }); +} + +export function showTextInputDialog(request: TextInputDialogRequest): void { + presentRequest?.({ kind: "text-input", request }); } /** @@ -33,42 +50,64 @@ export function showConfirmDialog(request: ConfirmDialogRequest): void { * button color and a dimmer message than the title. */ export function ConfirmDialogHost() { - const [request, setRequest] = useState(null); + const [presented, setPresented] = useState(null); + const [inputValue, setInputValue] = useState(""); useEffect(() => { - presentRequest = setRequest; + presentRequest = (request) => { + setInputValue(request.kind === "text-input" ? request.request.initialValue : ""); + setPresented(request); + }; return () => { presentRequest = null; }; }, []); const handleCancel = useCallback(() => { - request?.onCancel?.(); - setRequest(null); - }, [request]); + presented?.request.onCancel?.(); + setPresented(null); + }, [presented]); const handleConfirm = useCallback(() => { - request?.onConfirm(); - setRequest(null); - }, [request]); + if (presented?.kind === "confirm") { + presented.request.onConfirm(); + } else if (presented?.kind === "text-input") { + presented.request.onConfirm(inputValue); + } + setPresented(null); + }, [inputValue, presented]); + + const confirmDisabled = presented?.kind === "text-input" && inputValue.trim().length === 0; return ( - {request === null ? null : ( + {presented === null ? null : ( - {request.title} - {request.message === undefined ? null : ( + {presented.request.title} + {presented.kind === "confirm" && presented.request.message !== undefined ? ( - {request.message} + {presented.request.message} - )} + ) : null} + {presented.kind === "text-input" ? ( + + ) : null} - {request.cancelText ?? "Cancel"} + {presented.request.cancelText ?? "Cancel"} - {request.confirmText} + {presented.request.confirmText} diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 00731ce5aeec362316509f0ab92759cea2436cf3..b2da5af8ed269fe5294796c02b621db525320c7c 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -50,6 +50,7 @@ export function HomeRouteScreen() { pinThread, unpinThread, moveThread, + renameThread, regenerateThreadTitle, unsettleThread, } = useThreadListActions(); @@ -200,6 +201,7 @@ export function HomeRouteScreen() { onPinThread={pinThread} onUnpinThread={unpinThread} onMoveThread={moveThread} + onRenameThread={renameThread} onRegenerateThreadTitle={regenerateThreadTitle} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 2b933c50315e1262b4d598f843eb2007be4a034a..820f80f2d63252b1145880214573711cb7795be5 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -126,6 +126,7 @@ interface HomeScreenProps { thread: EnvironmentThreadShell, direction: ThreadMoveDestination, ) => Promise; + readonly onRenameThread: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; @@ -538,6 +539,10 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onRegenerateThreadTitle], ); + const handleRenameThread = useCallback( + (thread: EnvironmentThreadShell) => props.onRenameThread(thread), + [props.onRenameThread], + ); const handleDeleteThread = props.onDeleteThread; const handleUnsettleThread = props.onUnsettleThread; // The settled tail renders in pages; expansion resets when the filter @@ -868,6 +873,7 @@ export function HomeScreen(props: HomeScreenProps) { onSelectThread={props.onSelectThread} onDeleteThread={handleDeleteThread} onArchiveThread={props.onArchiveThread} + onRenameThread={handleRenameThread} onRegenerateThreadTitle={handleRegenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} @@ -901,6 +907,7 @@ export function HomeScreen(props: HomeScreenProps) { handleMoveThread, handlePinThread, handleRegenerateThreadTitle, + handleRenameThread, handleSettleThread, handleSnoozeThread, handleUnpinThread, @@ -1025,6 +1032,7 @@ export function HomeScreen(props: HomeScreenProps) { searchQuery={props.searchQuery} onArchiveThread={props.onArchiveThread} onDeleteThread={props.onDeleteThread} + onRenameThread={handleRenameThread} onRegenerateThreadTitle={handleRegenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} onSelectThread={props.onSelectThread} @@ -1049,6 +1057,7 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableClose, handleSwipeableWillOpen, handleRegenerateThreadTitle, + handleRenameThread, machineByEnvironmentId, queuedThreadKeys, props.onArchiveThread, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 72e45c9bbb4eff5e1359b50224569ce99c2616cf..9b599110883cd69caa7095f7d07a02cfef9c4e67 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -4,10 +4,10 @@ import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/threa import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; -import { Alert } from "react-native"; +import { Alert, Platform } from "react-native"; import { withThreadDismissal } from "./thread-dismissal"; -import { showConfirmDialog } from "../../components/ConfirmDialogHost"; +import { showConfirmDialog, showTextInputDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; @@ -27,6 +27,7 @@ import { threadDropLifecycle, } from "../threads/threadOrder"; import { getThreadListV2OrderedSection } from "../threads/threadListV2"; +import { resolveThreadTitleRename } from "../threads/thread-title-rename"; /** Version skew: never send settle/unsettle to a server that predates them (capability defaults false on decode for older servers). */ @@ -240,6 +241,7 @@ export function useThreadListActions(): { thread: EnvironmentThreadShell, direction: ThreadMoveDestination, ) => Promise; + readonly renameThread: (thread: EnvironmentThreadShell) => void; readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise; } { const executeAction = useThreadActionExecutor(); @@ -474,6 +476,50 @@ export function useThreadListActions(): { }, [updateThreadMetadata], ); + const renameThread = useCallback( + (thread: EnvironmentThreadShell) => { + const commit = (title: string) => { + const resolution = resolveThreadTitleRename({ title, originalTitle: thread.title }); + if (resolution.action === "reject-empty") { + Alert.alert("Could not rename thread", "Thread title cannot be empty."); + return; + } + if (resolution.action === "noop") return; + selectionHaptic(); + void updateThreadMetadata({ + environmentId: thread.environmentId, + input: { threadId: thread.id, title: resolution.title }, + }).then((result) => { + if (result._tag === "Success") return; + const error = Cause.squash(result.cause); + Alert.alert( + "Could not rename thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread could not be renamed.", + ); + }); + }; + + if (Platform.OS === "ios") { + Alert.prompt( + "Rename thread", + undefined, + (title) => commit(title ?? ""), + "plain-text", + thread.title, + ); + return; + } + showTextInputDialog({ + title: "Rename thread", + initialValue: thread.title, + confirmText: "Rename", + onConfirm: commit, + }); + }, + [updateThreadMetadata], + ); // Plan against the complete section so filtering does not change a move. const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, { @@ -653,6 +699,7 @@ export function useThreadListActions(): { pinThread, unpinThread, moveThread, + renameThread, regenerateThreadTitle, }; } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 0fb3aa5e6537f6f6e53bbcc4be0e67bec2e877c0..26093e87536f6b3027d190f62022925f2086763d 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -171,6 +171,7 @@ function ThreadNavigationSidebarPane( pinThread, unpinThread, moveThread, + renameThread, regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); @@ -932,6 +933,7 @@ function ThreadNavigationSidebarPane( onSelectThread={handleSelectThread} onDeleteThread={confirmDeleteThread} onArchiveThread={archiveThread} + onRenameThread={renameThread} onRegenerateThreadTitle={regenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} @@ -1049,6 +1051,7 @@ function ThreadNavigationSidebarPane( fullSwipeWidth={props.width - 20} onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} + onRenameThread={renameThread} onRegenerateThreadTitle={regenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} onSelectThread={handleSelectThread} @@ -1091,6 +1094,7 @@ function ThreadNavigationSidebarPane( projectByKey, projectTitleByProjectKey, regenerateThreadTitle, + renameThread, props.onNewThreadInProject, props.onNewThreadOnBranch, props.searchQuery, diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index be6ca24b4001a18af049653fb7a78e68b6252cb7..af71ab61344077a6e8a2f57ad45f6d516c46b6bb 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -437,6 +437,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [ { id: "archive", title: "Archive", image: "archivebox" }, + { id: "rename", title: "Rename", image: "square.and.pencil" }, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; @@ -458,6 +459,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; + readonly onRenameThread: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly titleRegenerationSupported: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; @@ -484,6 +486,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { onSelectThread, onArchiveThread, onDeleteThread, + onRenameThread, onRegenerateThreadTitle, onNewThreadOnBranch, } = props; @@ -525,6 +528,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); const handleRegenerateTitle = useCallback( () => onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, thread], @@ -542,11 +546,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ] : []), THREAD_ROW_MENU_ACTIONS[0]!, + THREAD_ROW_MENU_ACTIONS[1]!, ...buildThreadTitleRegenerationMenuItems({ supported: props.titleRegenerationSupported, isRegenerating: thread.titleRegeneration != null, }), - THREAD_ROW_MENU_ACTIONS[1]!, + THREAD_ROW_MENU_ACTIONS[2]!, ], [props.titleRegenerationSupported, thread.branch, thread.titleRegeneration], ); @@ -563,10 +568,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread); if (nativeEvent.event === "archive") handleArchive(); + if (nativeEvent.event === "rename") handleRename(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "delete") handleDelete(); }, - [handleArchive, handleDelete, handleRegenerateTitle, onNewThreadOnBranch, thread], + [handleArchive, handleDelete, handleRegenerateTitle, handleRename, onNewThreadOnBranch, thread], ); const statusPill = effectiveStatus ? ( diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 60f96b2a06f31aec13af06ac426c80cf77837819..02870c3ff22e7c3e8b552bf059992f8b34ffaf30 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -382,6 +382,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; + readonly onRenameThread: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void; @@ -423,6 +424,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { variant, onSelectThread, onDeleteThread, + onRenameThread, onRegenerateThreadTitle, onNewThreadOnBranch, onSettleThread, @@ -468,6 +470,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { settledTimestamp !== null ? relativeTime(settledTimestamp) : threadTimeLabel(thread); const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); + const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); const handleRegenerateTitle = useCallback( () => onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, thread], @@ -560,12 +563,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { variant, ], ); - const titleRegenerationMenuItems = useMemo( - () => - buildThreadTitleRegenerationMenuItems({ + const titleMenuItems = useMemo( + () => [ + { id: "rename", title: "Rename", image: "square.and.pencil" }, + ...buildThreadTitleRegenerationMenuItems({ supported: props.titleRegenerationSupported, isRegenerating: thread.titleRegeneration != null, }), + ], [props.titleRegenerationSupported, thread.titleRegeneration], ); const snoozableCardMenuActions = useMemo( @@ -578,19 +583,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { subactions: snoozePresetActions, }, ...arrangementMenuItems, - ...titleRegenerationMenuItems, + ...titleMenuItems, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], - [arrangementMenuItems, snoozePresetActions, titleRegenerationMenuItems], + [arrangementMenuItems, snoozePresetActions, titleMenuItems], ); const cardMenuActions = useMemo( () => [ CARD_MENU_ACTIONS[0]!, ...arrangementMenuItems, - ...titleRegenerationMenuItems, + ...titleMenuItems, ...CARD_MENU_ACTIONS.slice(1), ], - [arrangementMenuItems, titleRegenerationMenuItems], + [arrangementMenuItems, titleMenuItems], ); const slimMenuActions = useMemo( () => [ @@ -598,23 +603,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ...arrangementMenuItems.filter( (action) => action.id !== "move-up" && action.id !== "move-down", ), - ...titleRegenerationMenuItems, + ...titleMenuItems, SLIM_MENU_ACTIONS[1]!, ], - [arrangementMenuItems, titleRegenerationMenuItems], + [arrangementMenuItems, titleMenuItems], ); const snoozedMenuActions = useMemo( - () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [SNOOZED_MENU_ACTIONS[0]!, ...titleMenuItems, SNOOZED_MENU_ACTIONS[1]!], + [titleMenuItems], ); const legacyMenuActions = useMemo( () => [ LEGACY_MENU_ACTIONS[0]!, ...arrangementMenuItems, - ...titleRegenerationMenuItems, + ...titleMenuItems, LEGACY_MENU_ACTIONS[1]!, ], - [arrangementMenuItems, titleRegenerationMenuItems], + [arrangementMenuItems, titleMenuItems], ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -628,6 +633,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "move-up") handleMoveUp(); if (nativeEvent.event === "move-down") handleMoveDown(); if (nativeEvent.event === "archive") handleArchive(); + if (nativeEvent.event === "rename") handleRename(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "delete") handleDelete(); if (nativeEvent.event === "snooze:custom") { @@ -651,6 +657,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleArchive, handleDelete, handleRegenerateTitle, + handleRename, handleMoveDown, handleMoveUp, handlePin, diff --git a/apps/mobile/src/features/threads/thread-title-rename.test.ts b/apps/mobile/src/features/threads/thread-title-rename.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..75bc5cdc4133de9f6d26a72cbd49a6cc0b6fc202 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-title-rename.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveThreadTitleRename } from "./thread-title-rename"; + +describe("resolveThreadTitleRename", () => { + it("trims a changed title", () => { + expect(resolveThreadTitleRename({ title: " New title ", originalTitle: "Old" })).toEqual({ + action: "rename", + title: "New title", + }); + }); + + it("rejects empty and unchanged titles", () => { + expect(resolveThreadTitleRename({ title: " ", originalTitle: "Old" })).toEqual({ + action: "reject-empty", + }); + expect(resolveThreadTitleRename({ title: " Old ", originalTitle: "Old" })).toEqual({ + action: "noop", + }); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-title-rename.ts b/apps/mobile/src/features/threads/thread-title-rename.ts new file mode 100644 index 0000000000000000000000000000000000000000..23f3e449eff2ec56938680cab911838781f1c82a --- /dev/null +++ b/apps/mobile/src/features/threads/thread-title-rename.ts @@ -0,0 +1,14 @@ +export type ThreadTitleRenameResolution = + | { readonly action: "rename"; readonly title: string } + | { readonly action: "noop" } + | { readonly action: "reject-empty" }; + +export function resolveThreadTitleRename(input: { + readonly title: string; + readonly originalTitle: string; +}): ThreadTitleRenameResolution { + const title = input.title.trim(); + if (title.length === 0) return { action: "reject-empty" }; + if (title === input.originalTitle) return { action: "noop" }; + return { action: "rename", title }; +}