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..3b122785ab03
--- /dev/null
+++ b/apps/mobile/src/components/RenameThreadDialogHost.tsx
@@ -0,0 +1,142 @@
+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";
+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) => (