From d21ac12790070c974792c14a6aacd1a143a5d087 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Sat, 8 Aug 2026 00:34:38 +0200 Subject: [PATCH 1/2] feat(mobile): rename a thread from the row menu The thread row long-press menu only offered Archive and Delete. Add a "Rename" item that opens a prefilled dialog and dispatches thread.meta.update { title }, matching the web per-thread action menu. - New RenameThreadDialogHost: a cross-platform text-input dialog (RN's Alert.prompt is iOS-only, so Android needs a custom modal), mounted at the app root next to ConfirmDialogHost. Rename stays disabled until the trimmed value is non-empty, and an unchanged title dispatches nothing. - useThreadListActions gains renameThread, reusing the existing updateMetadata command. - Both the v1 (ThreadListRow) and v2 (ThreadListV2Row) row menus render the item across every variant, on both the Home list and the iPad sidebar. Co-Authored-By: Claude Opus 4.8 --- apps/mobile/src/App.tsx | 2 + .../src/components/RenameThreadDialogHost.tsx | 137 ++++++++++++++++++ .../src/features/home/HomeRouteScreen.tsx | 2 + apps/mobile/src/features/home/HomeScreen.tsx | 5 + .../src/features/home/useThreadListActions.ts | 33 +++++ .../threads/ThreadNavigationSidebar.tsx | 4 + .../features/threads/thread-list-items.tsx | 34 ++++- .../features/threads/thread-list-v2-items.tsx | 23 ++- 8 files changed, 230 insertions(+), 10 deletions(-) create mode 100644 apps/mobile/src/components/RenameThreadDialogHost.tsx diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 06bd4bc57733..0bfb03afc8c5 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -10,6 +10,7 @@ import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigati import { RegistryContext } from "@effect/atom-react"; import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; +import { RenameThreadDialogHost } from "./components/RenameThreadDialogHost"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; @@ -88,6 +89,7 @@ export default function App() { /> + {/* Anchored-menu overlays render here — in-window, so the keyboard stays up while a dropdown is open. */} diff --git a/apps/mobile/src/components/RenameThreadDialogHost.tsx b/apps/mobile/src/components/RenameThreadDialogHost.tsx new file mode 100644 index 000000000000..ebe4d2f2d71f --- /dev/null +++ b/apps/mobile/src/components/RenameThreadDialogHost.tsx @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Modal, Pressable, TextInput, View } from "react-native"; + +import { useThemeColor } from "../lib/useThemeColor"; +import { cn } from "../lib/cn"; +import { AppText, AppTextInput } from "./AppText"; + +export type RenameThreadDialogRequest = { + /** Dialog heading. Defaults to "Rename thread". */ + readonly title?: string; + /** Prefills the field; the user edits from here. */ + readonly initialValue: string; + readonly confirmText?: string; + readonly cancelText?: string; + /** Called with the trimmed value only when it is non-empty and changed. */ + readonly onSubmit: (value: string) => void; + readonly onCancel?: () => void; +}; + +let presentRequest: ((request: RenameThreadDialogRequest) => void) | null = null; + +/** + * Imperative rename dialog. React Native's Alert.prompt is iOS-only, so + * renaming a thread needs a custom modal to work on Android — this is the + * text-input sibling of showConfirmDialog. Requires RenameThreadDialogHost to + * be mounted at the app root. + */ +export function showRenameThreadDialog(request: RenameThreadDialogRequest): void { + presentRequest?.(request); +} + +/** + * Single-field rename dialog styled to match ConfirmDialogHost: a centered + * card with a title, a prefilled text field, and Cancel / Rename actions. + * Rename stays disabled until the trimmed value is non-empty. + */ +export function RenameThreadDialogHost() { + const [request, setRequest] = useState(null); + const [value, setValue] = useState(""); + const inputRef = useRef(null); + const pressedOverlay = useThemeColor("--color-subtle"); + + useEffect(() => { + presentRequest = (next) => { + setRequest(next); + setValue(next.initialValue); + }; + return () => { + presentRequest = null; + }; + }, []); + + const handleCancel = useCallback(() => { + request?.onCancel?.(); + setRequest(null); + }, [request]); + + const trimmed = value.trim(); + const canSubmit = trimmed.length > 0; + + const handleConfirm = useCallback(() => { + if (request === null) return; + const next = value.trim(); + if (next.length === 0) return; + // A no-op rename still dismisses the dialog, but never dispatches a + // pointless metadata update for an unchanged title. + if (next !== request.initialValue.trim()) { + request.onSubmit(next); + } + setRequest(null); + }, [request, value]); + + return ( + inputRef.current?.focus()} + > + {request === null ? null : ( + + + {request.title ?? "Rename thread"} + + + + + + {request.cancelText ?? "Cancel"} + + + + + + + {request.confirmText ?? "Rename"} + + + + + + + )} + + ); +} diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 7760920f7dbd..be79c9c09574 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -46,6 +46,7 @@ export function HomeRouteScreen() { unpinThread, movePinnedThread, unsettleThread, + renameThread, } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); @@ -165,6 +166,7 @@ export function HomeRouteScreen() { } onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} + onRenameThread={renameThread} onSettleThread={settleThread} onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 64f0480d2231..ddccf9d3f686 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -101,6 +101,7 @@ interface HomeScreenProps { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onRenameThread: (thread: EnvironmentThreadShell) => void; /** Resolves true iff the settle was dispatched and succeeded. */ readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; readonly onSnoozeThread: ( @@ -811,6 +812,7 @@ export function HomeScreen(props: HomeScreenProps) { onSelectThread={props.onSelectThread} onDeleteThread={handleDeleteThread} onArchiveThread={props.onArchiveThread} + onRenameThread={props.onRenameThread} settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} onSettleThread={handleSettleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} @@ -854,6 +856,7 @@ export function HomeScreen(props: HomeScreenProps) { projectByKey, projectCwdByKey, props.onArchiveThread, + props.onRenameThread, props.onDeletePendingTask, props.onSelectPendingTask, props.onSelectThread, @@ -967,6 +970,7 @@ export function HomeScreen(props: HomeScreenProps) { searchQuery={props.searchQuery} onArchiveThread={props.onArchiveThread} onDeleteThread={props.onDeleteThread} + onRenameThread={props.onRenameThread} onSelectThread={props.onSelectThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -992,6 +996,7 @@ export function HomeScreen(props: HomeScreenProps) { props.onArchiveThread, props.onDeletePendingTask, props.onDeleteThread, + props.onRenameThread, props.onNewThreadInProject, props.onSelectPendingTask, props.onSelectThread, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 3103c5379be2..b2b89cc1d7c3 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -6,6 +6,7 @@ import { useCallback, useRef } from "react"; import { Alert } from "react-native"; import { showConfirmDialog } from "../../components/ConfirmDialogHost"; +import { showRenameThreadDialog } from "../../components/RenameThreadDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; import { @@ -227,8 +228,12 @@ export function useThreadListActions(): { thread: EnvironmentThreadShell, direction: "up" | "down", ) => Promise; + readonly renameThread: (thread: EnvironmentThreadShell) => void; } { const executeAction = useThreadActionExecutor(); + const updateMetadataMutation = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false }); @@ -485,6 +490,33 @@ export function useThreadListActions(): { [reorderPinnedMutation], ); + const renameThread = useCallback( + (thread: EnvironmentThreadShell) => { + showRenameThreadDialog({ + initialValue: thread.title, + onSubmit: (title) => { + void (async () => { + selectionHaptic(); + const result = await updateMetadataMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, title }, + }); + 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.", + ); + } + })(); + }, + }); + }, + [updateMetadataMutation], + ); + const confirmDeleteThread = useConfirmDeleteThread(executeAction); return { @@ -497,6 +529,7 @@ export function useThreadListActions(): { pinThread, unpinThread, movePinnedThread, + renameThread, }; } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index d80d906ada17..4d63f719a1b1 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -211,6 +211,7 @@ function ThreadNavigationSidebarPane( pinThread, unpinThread, movePinnedThread, + renameThread, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); @@ -950,6 +951,7 @@ function ThreadNavigationSidebarPane( onSelectThread={handleSelectThread} onDeleteThread={confirmDeleteThread} onArchiveThread={archiveThread} + onRenameThread={renameThread} settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} onSettleThread={settleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} @@ -1067,6 +1069,7 @@ function ThreadNavigationSidebarPane( fullSwipeWidth={props.width - 20} onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} + onRenameThread={renameThread} onSelectThread={handleSelectThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -1097,6 +1100,7 @@ function ThreadNavigationSidebarPane( handleSwipeableWillOpen, movePinnedThread, openPendingTask, + renameThread, pinReorderEnvironmentIds, pinThread, pinningEnvironmentIds, diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 855713946ff8..b7182bc2bbd9 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -409,10 +409,17 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { /* ─── Thread row ─────────────────────────────────────────────────────── */ -const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [ - { id: "archive", title: "Archive", image: "archivebox" }, - { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, -]; +const THREAD_ROW_MENU_ARCHIVE: MenuAction = { + id: "archive", + title: "Archive", + image: "archivebox", +}; +const THREAD_ROW_MENU_DELETE: MenuAction = { + id: "delete", + title: "Delete", + image: "trash", + attributes: { destructive: true }, +}; export const ThreadListRow = memo(function ThreadListRow(props: { readonly variant: ThreadListVariant; @@ -429,6 +436,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + /** Adds a "Rename" menu item when provided. */ + readonly onRenameThread?: (thread: EnvironmentThreadShell) => void; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly simultaneousSwipeGesture?: ComponentProps< @@ -451,6 +460,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const selectedBackgroundColor = useThemeColor("--color-user-bubble"); const { thread, onSelectThread, onArchiveThread, onDeleteThread } = props; + const { onRenameThread } = props; const status = resolveThreadStatus(thread); const pr = useThreadPr(thread, props.projectCwd); const timestamp = relativeTime( @@ -470,6 +480,17 @@ 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 menuActions = useMemo( + () => [ + ...(onRenameThread != null + ? [{ id: "rename", title: "Rename", image: "square.and.pencil" } satisfies MenuAction] + : []), + THREAD_ROW_MENU_ARCHIVE, + THREAD_ROW_MENU_DELETE, + ], + [onRenameThread], + ); const primaryAction = useMemo( () => ({ accessibilityLabel: `Archive ${thread.title}`, @@ -481,10 +502,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "rename") handleRename(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "delete") handleDelete(); }, - [handleArchive, handleDelete], + [handleArchive, handleDelete, handleRename], ); const statusPill = effectiveStatus ? ( @@ -673,7 +695,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { // ControlPillMenu injects onLongPress into the row and anchors the // token-styled dropdown to it; taps and swipes are untouched. 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 eb618146172e..eb44fffc45a5 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -345,6 +345,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; + /** Adds a "Rename" menu item when provided. */ + readonly onRenameThread?: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => void; readonly onUnpinThread: (thread: EnvironmentThreadShell) => void; /** False on environments whose server predates thread.settle/unsettle: @@ -388,6 +390,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onUnsnoozeThread, onUnsettleThread, onArchiveThread, + onRenameThread, onPinThread, onUnpinThread, onMovePinnedThread, @@ -434,6 +437,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [onMovePinnedThread, thread], ); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + const handleRename = useCallback(() => onRenameThread?.(thread), [onRenameThread, thread]); + // Rename leads every variant of the row menu, above the lifecycle and delete + // items, so both surfaces (this list and the v1 list) expose it regardless + // of settle/snooze/pin state. + const titleMenuItems = useMemo( + () => + onRenameThread != null ? [{ id: "rename", title: "Rename", image: "square.and.pencil" }] : [], + [onRenameThread], + ); // Swipe: the v2 primary action is the lifecycle transition. Every settled // row can un-settle — explicit settles clear the override, auto-settled @@ -525,6 +537,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "rename") handleRename(); if (nativeEvent.event === "settle") handleSettle(); if (nativeEvent.event === "unsettle") handleUnsettle(); if (nativeEvent.event === "unsnooze") handleUnsnooze(); @@ -551,6 +564,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleMovePinnedDown, handleMovePinnedUp, handlePin, + handleRename, handleSettle, handleSnooze, handleUnpin, @@ -885,8 +899,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { > {(close) => ( From d7cb11af080010425187357c4b273a063f692b0a Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Sat, 8 Aug 2026 00:56:33 +0200 Subject: [PATCH 2/2] fix(mobile): keep the rename dialog above the keyboard The rename dialog is a centered edge-to-edge Modal with an autofocused field; on Android the soft keyboard could overlay the card, hiding the input and actions. Wrap the content in KeyboardAvoidingView (from react-native-keyboard-controller, the same helper the composer sheets use) so the card lifts above the keyboard. Co-Authored-By: Claude Opus 4.8 --- .../src/components/RenameThreadDialogHost.tsx | 97 ++++++++++--------- 1 file changed, 51 insertions(+), 46 deletions(-) diff --git a/apps/mobile/src/components/RenameThreadDialogHost.tsx b/apps/mobile/src/components/RenameThreadDialogHost.tsx index ebe4d2f2d71f..3b122785ab03 100644 --- a/apps/mobile/src/components/RenameThreadDialogHost.tsx +++ b/apps/mobile/src/components/RenameThreadDialogHost.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Modal, Pressable, TextInput, View } from "react-native"; +import { KeyboardAvoidingView } from "react-native-keyboard-controller"; import { useThemeColor } from "../lib/useThemeColor"; import { cn } from "../lib/cn"; @@ -81,56 +82,60 @@ export function RenameThreadDialogHost() { onShow={() => inputRef.current?.focus()} > {request === null ? null : ( - - - {request.title ?? "Rename thread"} - - - - - - {request.cancelText ?? "Cancel"} - - - - - - + + + + {request.title ?? "Rename thread"} + + + + + + + {request.cancelText ?? "Cancel"} + + + + + - {request.confirmText ?? "Rename"} - - + + {request.confirmText ?? "Rename"} + + + - + )} );