diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index 81daa3d6a2da..13373b725a50 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useState } from "react"; import { Modal, Pressable, View } from "react-native"; +import { KeyboardAvoidingView } from "react-native-keyboard-controller"; import { useThemeColor } from "../lib/useThemeColor"; import { cn } from "../lib/cn"; -import { AppText } from "./AppText"; +import { AppText, AppTextInput as TextInput } from "./AppText"; export type ConfirmDialogRequest = { readonly title: string; @@ -15,7 +16,24 @@ export type ConfirmDialogRequest = { readonly onCancel?: () => void; }; -let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null; +export type PromptDialogRequest = { + readonly title: string; + readonly message?: string; + readonly placeholder?: string; + /** Prefills the field and is selected on open, so typing replaces it. */ + readonly initialValue?: string; + readonly cancelText?: string; + readonly confirmText: string; + /** Receives the raw field text; the caller owns trimming and no-op rules. */ + readonly onConfirm: (value: string) => void; + readonly onCancel?: () => void; +}; + +type DialogRequest = + | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest } + | { readonly kind: "prompt"; readonly request: PromptDialogRequest }; + +let presentDialog: ((dialog: DialogRequest) => void) | null = null; /** * Imperative confirm dialog, Alert.alert-shaped. Native iOS alerts already @@ -24,7 +42,16 @@ 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); + presentDialog?.({ kind: "confirm", request }); +} + +/** + * Imperative single-field text prompt. Unlike showConfirmDialog this is the + * only option on both platforms: Alert.prompt is iOS-only. Confirm stays + * disabled while the field is blank. Requires ConfirmDialogHost at the root. + */ +export function showPromptDialog(request: PromptDialogRequest): void { + presentDialog?.({ kind: "prompt", request }); } /** @@ -34,77 +61,108 @@ export function showConfirmDialog(request: ConfirmDialogRequest): void { * button color and a dimmer message than the title. */ export function ConfirmDialogHost() { - const [request, setRequest] = useState(null); + const [dialog, setDialog] = useState(null); + const [draft, setDraft] = useState(""); const pressedOverlay = useThemeColor("--color-subtle"); useEffect(() => { - presentRequest = setRequest; + presentDialog = (next) => { + setDialog(next); + setDraft(next.kind === "prompt" ? (next.request.initialValue ?? "") : ""); + }; return () => { - presentRequest = null; + presentDialog = null; }; }, []); const handleCancel = useCallback(() => { - request?.onCancel?.(); - setRequest(null); - }, [request]); + dialog?.request.onCancel?.(); + setDialog(null); + }, [dialog]); + + const confirmDisabled = dialog?.kind === "prompt" && draft.trim().length === 0; const handleConfirm = useCallback(() => { - request?.onConfirm(); - setRequest(null); - }, [request]); + if (dialog === null) return; + if (dialog.kind === "prompt") { + if (draft.trim().length === 0) return; + dialog.request.onConfirm(draft); + } else { + dialog.request.onConfirm(); + } + setDialog(null); + }, [dialog, draft]); return ( - {request === null ? null : ( - - - {request.title} - {request.message === undefined ? null : ( - - {request.message} - - )} - - - - - {request.cancelText ?? "Cancel"} - - - - - - + + + {dialog.request.title} + {dialog.request.message === undefined ? null : ( + + {dialog.request.message} + + )} + {dialog.kind === "prompt" ? ( + + ) : null} + + + + + {dialog.request.cancelText ?? "Cancel"} + + + + + - {request.confirmText} - - + + {dialog.request.confirmText} + + + - + )} ); diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index beabf66d9ea9..b05181fa5474 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -47,6 +47,7 @@ export function HomeRouteScreen() { pinThread, unpinThread, movePinnedThread, + renameThread, regenerateThreadTitle, unsettleThread, } = useThreadListActions(); @@ -195,6 +196,7 @@ export function HomeRouteScreen() { onPinThread={pinThread} onUnpinThread={unpinThread} onMovePinnedThread={movePinnedThread} + 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 0026876696d6..2f898180c793 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -117,6 +117,7 @@ interface HomeScreenProps { thread: EnvironmentThreadShell, direction: "up" | "down", ) => Promise; + readonly onRenameThread: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; @@ -845,6 +846,7 @@ export function HomeScreen(props: HomeScreenProps) { onSelectThread={props.onSelectThread} onDeleteThread={handleDeleteThread} onArchiveThread={props.onArchiveThread} + onRenameThread={props.onRenameThread} onRegenerateThreadTitle={handleRegenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} @@ -1006,6 +1008,7 @@ export function HomeScreen(props: HomeScreenProps) { searchQuery={props.searchQuery} onArchiveThread={props.onArchiveThread} onDeleteThread={props.onDeleteThread} + onRenameThread={props.onRenameThread} onRegenerateThreadTitle={handleRegenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} onSelectThread={props.onSelectThread} diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 5c66944042ad..f2d50b77dacb 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -5,7 +5,7 @@ import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; import { Alert } from "react-native"; -import { showConfirmDialog } from "../../components/ConfirmDialogHost"; +import { showConfirmDialog, showPromptDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; import { @@ -236,6 +236,7 @@ export function useThreadListActions(): { thread: EnvironmentThreadShell, direction: "up" | "down", ) => Promise; + readonly renameThread: (thread: EnvironmentThreadShell) => void; readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise; } { const executeAction = useThreadActionExecutor(); @@ -419,6 +420,45 @@ export function useThreadListActions(): { }, [unpinMutation], ); + /** Same commit rule as web's resolveRenameCommit: trim, drop an empty + title, and skip the write when nothing changed. */ + const commitRename = useCallback( + async (thread: EnvironmentThreadShell, title: string) => { + const trimmed = title.trim(); + if (trimmed.length === 0 || trimmed === thread.title) return; + selectionHaptic(); + const result = await updateThreadMetadata({ + environmentId: thread.environmentId, + input: { threadId: thread.id, title: trimmed }, + }); + if (result._tag === "Failure") { + 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.", + ); + } + }, + [updateThreadMetadata], + ); + /** Prompts for a new title, then writes it. Not capability-gated: every + server that accepts thread.meta.update accepts a manual title. */ + const renameThread = useCallback( + (thread: EnvironmentThreadShell) => { + showPromptDialog({ + title: "Rename thread", + confirmText: "Rename", + initialValue: thread.title, + placeholder: "Thread title", + onConfirm: (value) => { + void commitRename(thread, value); + }, + }); + }, + [commitRename], + ); const regenerateThreadTitle = useCallback( async (thread: EnvironmentThreadShell) => { const key = scopedThreadKey(thread.environmentId, thread.id); @@ -552,6 +592,7 @@ export function useThreadListActions(): { pinThread, unpinThread, movePinnedThread, + renameThread, regenerateThreadTitle, }; } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 6feca0013527..a55c77af7399 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -170,6 +170,7 @@ function ThreadNavigationSidebarPane( pinThread, unpinThread, movePinnedThread, + renameThread, regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); @@ -923,6 +924,7 @@ function ThreadNavigationSidebarPane( onSelectThread={handleSelectThread} onDeleteThread={confirmDeleteThread} onArchiveThread={archiveThread} + onRenameThread={renameThread} onRegenerateThreadTitle={regenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} @@ -1044,6 +1046,7 @@ function ThreadNavigationSidebarPane( fullSwipeWidth={props.width - 20} onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} + onRenameThread={renameThread} onRegenerateThreadTitle={regenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} onSelectThread={handleSelectThread} @@ -1083,6 +1086,7 @@ function ThreadNavigationSidebarPane( projectCwdByKey, projectTitleByProjectKey, regenerateThreadTitle, + renameThread, props.onNewThreadInProject, props.searchQuery, props.selectedThreadKey, diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 78e6e43c075d..ad7764c74208 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -415,6 +415,8 @@ const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; +const RENAME_MENU_ACTION = { id: "rename", title: "Rename", image: "pencil" } satisfies MenuAction; + export const ThreadListRow = memo(function ThreadListRow(props: { readonly variant: ThreadListVariant; readonly thread: EnvironmentThreadShell; @@ -430,6 +432,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onRenameThread: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly titleRegenerationSupported: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; @@ -454,8 +457,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const selectedBackgroundColor = useThemeColor("--color-user-bubble"); const selectedForegroundColor = useThemeColor("--color-user-bubble-foreground"); - const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } = - props; + const { + thread, + onSelectThread, + onArchiveThread, + onDeleteThread, + onRenameThread, + onRegenerateThreadTitle, + } = props; const status = resolveThreadStatus(thread); const pr = useThreadPr(thread, props.projectCwd); const timestamp = relativeTime( @@ -481,6 +490,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], @@ -488,6 +498,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const menuActions = useMemo( () => [ THREAD_ROW_MENU_ACTIONS[0]!, + RENAME_MENU_ACTION, ...buildThreadTitleRegenerationMenuItems({ supported: props.titleRegenerationSupported, isRegenerating: thread.titleRegeneration != null, @@ -508,10 +519,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "archive") handleArchive(); + if (nativeEvent.event === "rename") handleRename(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "delete") handleDelete(); }, - [handleArchive, handleDelete, handleRegenerateTitle], + [handleArchive, handleDelete, handleRegenerateTitle, handleRename], ); 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 c0322a0336fe..b003d3b4b875 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -64,7 +64,9 @@ function threadTimeLabel(thread: EnvironmentThreadShell): string { return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt); } -// Menus keep lifecycle and title regeneration together. Archive keeps its +const RENAME_MENU_ACTION = { id: "rename", title: "Rename", image: "pencil" } satisfies MenuAction; + +// Menus keep lifecycle and title actions together. Archive keeps its // own surface (thread screen / settings) rather than crowding v2 rows. const CARD_MENU_ACTIONS: MenuAction[] = [ { id: "settle", title: "Settle", image: "checkmark" }, @@ -346,6 +348,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly fullSwipeWidth?: number; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onRenameThread: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly onSettleThread: (thread: EnvironmentThreadShell) => void; readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void; @@ -392,6 +395,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { variant, onSelectThread, onDeleteThread, + onRenameThread, onRegenerateThreadTitle, onSettleThread, onSnoozeThread, @@ -430,6 +434,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const timeLabel = threadTimeLabel(thread); const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); + const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); const handleRegenerateTitle = useCallback( () => onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, thread], @@ -524,12 +529,16 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { thread.pinnedAt, ], ); - const titleRegenerationMenuItems = useMemo( - () => - buildThreadTitleRegenerationMenuItems({ + // Manual rename rides along with the title items so every menu that offers + // regeneration also offers it. Rename is never capability-gated. + const titleMenuItems = useMemo( + () => [ + RENAME_MENU_ACTION, + ...buildThreadTitleRegenerationMenuItems({ supported: props.titleRegenerationSupported, isRegenerating: thread.titleRegeneration != null, }), + ], [props.titleRegenerationSupported, thread.titleRegeneration], ); const snoozableCardMenuActions = useMemo( @@ -542,36 +551,31 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { subactions: snoozePresetActions, }, ...pinMenuItem, - ...titleRegenerationMenuItems, + ...titleMenuItems, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], - [pinMenuItem, snoozePresetActions, titleRegenerationMenuItems], + [pinMenuItem, snoozePresetActions, titleMenuItems], ); const cardMenuActions = useMemo( - () => [ - CARD_MENU_ACTIONS[0]!, - ...pinMenuItem, - ...titleRegenerationMenuItems, - ...CARD_MENU_ACTIONS.slice(1), - ], - [pinMenuItem, titleRegenerationMenuItems], + () => [CARD_MENU_ACTIONS[0]!, ...pinMenuItem, ...titleMenuItems, ...CARD_MENU_ACTIONS.slice(1)], + [pinMenuItem, titleMenuItems], ); const slimMenuActions = useMemo( () => [ SLIM_MENU_ACTIONS[0]!, ...(thread.pinnedAt != null ? pinMenuItem : []), - ...titleRegenerationMenuItems, + ...titleMenuItems, SLIM_MENU_ACTIONS[1]!, ], - [pinMenuItem, thread.pinnedAt, titleRegenerationMenuItems], + [pinMenuItem, thread.pinnedAt, 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]!, ...titleRegenerationMenuItems, LEGACY_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [LEGACY_MENU_ACTIONS[0]!, ...titleMenuItems, LEGACY_MENU_ACTIONS[1]!], + [titleMenuItems], ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -583,6 +587,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "move-pin-up") handleMovePinnedUp(); if (nativeEvent.event === "move-pin-down") handleMovePinnedDown(); if (nativeEvent.event === "archive") handleArchive(); + if (nativeEvent.event === "rename") handleRename(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "delete") handleDelete(); const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ @@ -599,6 +604,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [ handleArchive, handleDelete, + handleRename, handleRegenerateTitle, handleMovePinnedDown, handleMovePinnedUp,