authorgravatar for 107891771+Michel-Liao@users.noreply.github.comMichel Liao <107891771+Michel-Liao@users.noreply.github.com> 2026-09-15 13:26:06-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 10:26:06-07:00
logd1790aa14639a0361c02fbc5c326d0aa76f95a83
tree8ceb3b89bd9d54c479c103ab468e40034e233c7c
parent2a264adc6f6c65f98d4273acaa1af567eca83f2a
signature Signed by PGP key B5690EEEBB952194

fix(mobile): add missing thread rename action (#11503)


9 files changed, 190 insertions(+), 37 deletions(-)

apps/mobile/src/components/ConfirmDialogHost.tsx+63-20
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { useCallback, useEffect, useState } from "react";1import { useCallback, useEffect, useState } from "react";
2import { Modal, Pressable, View } from "react-native";2import { Modal, Pressable, TextInput, View } from "react-native";
33
4import { cn } from "../lib/cn";4import { cn } from "../lib/cn";
5import { AppText } from "./AppText";5import { AppText } from "./AppText";
...@@ -14,7 +14,20 @@ export type ConfirmDialogRequest = {...@@ -14,7 +14,20 @@ export type ConfirmDialogRequest = {
14 readonly onCancel?: () => void;14 readonly onCancel?: () => void;
15};15};
1616
17let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null;17export type TextInputDialogRequest = {
18 readonly title: string;
19 readonly initialValue: string;
20 readonly cancelText?: string;
21 readonly confirmText: string;
22 readonly onConfirm: (value: string) => void;
23 readonly onCancel?: () => void;
24};
25
26type DialogRequest =
27 | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest }
28 | { readonly kind: "text-input"; readonly request: TextInputDialogRequest };
29
30let presentRequest: ((request: DialogRequest) => void) | null = null;
1831
19/**32/**
20 * Imperative confirm dialog, Alert.alert-shaped. Native iOS alerts already33 * Imperative confirm dialog, Alert.alert-shaped. Native iOS alerts already
...@@ -23,7 +36,11 @@ let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null;...@@ -23,7 +36,11 @@ let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null;
23 * once. Requires ConfirmDialogHost to be mounted at the app root.36 * once. Requires ConfirmDialogHost to be mounted at the app root.
24 */37 */
25export function showConfirmDialog(request: ConfirmDialogRequest): void {38export function showConfirmDialog(request: ConfirmDialogRequest): void {
26 presentRequest?.(request);39 presentRequest?.({ kind: "confirm", request });
40}
41
42export function showTextInputDialog(request: TextInputDialogRequest): void {
43 presentRequest?.({ kind: "text-input", request });
27}44}
2845
29/**46/**
...@@ -33,42 +50,64 @@ export function showConfirmDialog(request: ConfirmDialogRequest): void {...@@ -33,42 +50,64 @@ export function showConfirmDialog(request: ConfirmDialogRequest): void {
33 * button color and a dimmer message than the title.50 * button color and a dimmer message than the title.
34 */51 */
35export function ConfirmDialogHost() {52export function ConfirmDialogHost() {
36 const [request, setRequest] = useState<ConfirmDialogRequest | null>(null);53 const [presented, setPresented] = useState<DialogRequest | null>(null);
54 const [inputValue, setInputValue] = useState("");
37 useEffect(() => {55 useEffect(() => {
38 presentRequest = setRequest;56 presentRequest = (request) => {
57 setInputValue(request.kind === "text-input" ? request.request.initialValue : "");
58 setPresented(request);
59 };
39 return () => {60 return () => {
40 presentRequest = null;61 presentRequest = null;
41 };62 };
42 }, []);63 }, []);
4364
44 const handleCancel = useCallback(() => {65 const handleCancel = useCallback(() => {
45 request?.onCancel?.();66 presented?.request.onCancel?.();
46 setRequest(null);67 setPresented(null);
47 }, [request]);68 }, [presented]);
4869
49 const handleConfirm = useCallback(() => {70 const handleConfirm = useCallback(() => {
50 request?.onConfirm();71 if (presented?.kind === "confirm") {
51 setRequest(null);72 presented.request.onConfirm();
52 }, [request]);73 } else if (presented?.kind === "text-input") {
74 presented.request.onConfirm(inputValue);
75 }
76 setPresented(null);
77 }, [inputValue, presented]);
78
79 const confirmDisabled = presented?.kind === "text-input" && inputValue.trim().length === 0;
5380
54 return (81 return (
55 <Modal82 <Modal
56 visible={request !== null}83 visible={presented !== null}
57 transparent84 transparent
58 animationType="fade"85 animationType="fade"
59 statusBarTranslucent86 statusBarTranslucent
60 navigationBarTranslucent87 navigationBarTranslucent
61 onRequestClose={handleCancel}88 onRequestClose={handleCancel}
62 >89 >
63 {request === null ? null : (90 {presented === null ? null : (
64 <View className="flex-1 items-center justify-center bg-backdrop px-8">91 <View className="flex-1 items-center justify-center bg-backdrop px-8">
65 <View className="w-full rounded-[24px] bg-card px-6 pb-4 pt-5">92 <View className="w-full rounded-[24px] bg-card px-6 pb-4 pt-5">
66 <AppText className="text-lg font-t3-medium">{request.title}</AppText>93 <AppText className="text-lg font-t3-medium">{presented.request.title}</AppText>
67 {request.message === undefined ? null : (94 {presented.kind === "confirm" && presented.request.message !== undefined ? (
68 <AppText className="mt-2 text-sm text-foreground-secondary">95 <AppText className="mt-2 text-sm text-foreground-secondary">
69 {request.message}96 {presented.request.message}
70 </AppText>97 </AppText>
71 )}98 ) : null}
99 {presented.kind === "text-input" ? (
100 <TextInput
101 accessibilityLabel={presented.request.title}
102 autoFocus
103 className="mt-4 rounded-xl border border-border bg-screen px-3 py-2.5 text-base text-foreground"
104 onChangeText={setInputValue}
105 onSubmitEditing={confirmDisabled ? undefined : handleConfirm}
106 returnKeyType="done"
107 selectTextOnFocus
108 value={inputValue}
109 />
110 ) : null}
72 <View className="mt-5 flex-row justify-end gap-1">111 <View className="mt-5 flex-row justify-end gap-1">
73 <View className="overflow-hidden rounded-full">112 <View className="overflow-hidden rounded-full">
74 <Pressable113 <Pressable
...@@ -77,23 +116,27 @@ export function ConfirmDialogHost() {...@@ -77,23 +116,27 @@ export function ConfirmDialogHost() {
77 onPress={handleCancel}116 onPress={handleCancel}
78 >117 >
79 <AppText className="text-base font-t3-medium">118 <AppText className="text-base font-t3-medium">
80 {request.cancelText ?? "Cancel"}119 {presented.request.cancelText ?? "Cancel"}
81 </AppText>120 </AppText>
82 </Pressable>121 </Pressable>
83 </View>122 </View>
84 <View className="overflow-hidden rounded-full">123 <View className="overflow-hidden rounded-full">
85 <Pressable124 <Pressable
86 accessibilityRole="button"125 accessibilityRole="button"
126 disabled={confirmDisabled}
87 className="min-h-10 items-center justify-center px-4 active:bg-subtle"127 className="min-h-10 items-center justify-center px-4 active:bg-subtle"
88 onPress={handleConfirm}128 onPress={handleConfirm}
89 >129 >
90 <AppText130 <AppText
91 className={cn(131 className={cn(
92 "text-base font-t3-medium",132 "text-base font-t3-medium",
93 request.destructive && "text-danger-foreground",133 presented.kind === "confirm" &&
134 presented.request.destructive &&
135 "text-danger-foreground",
136 confirmDisabled && "text-foreground-tertiary",
94 )}137 )}
95 >138 >
96 {request.confirmText}139 {presented.request.confirmText}
97 </AppText>140 </AppText>
98 </Pressable>141 </Pressable>
99 </View>142 </View>
apps/mobile/src/features/home/HomeRouteScreen.tsx+2
...@@ -50,6 +50,7 @@ export function HomeRouteScreen() {...@@ -50,6 +50,7 @@ export function HomeRouteScreen() {
50 pinThread,50 pinThread,
51 unpinThread,51 unpinThread,
52 moveThread,52 moveThread,
53 renameThread,
53 regenerateThreadTitle,54 regenerateThreadTitle,
54 unsettleThread,55 unsettleThread,
55 } = useThreadListActions();56 } = useThreadListActions();
...@@ -200,6 +201,7 @@ export function HomeRouteScreen() {...@@ -200,6 +201,7 @@ export function HomeRouteScreen() {
200 onPinThread={pinThread}201 onPinThread={pinThread}
201 onUnpinThread={unpinThread}202 onUnpinThread={unpinThread}
202 onMoveThread={moveThread}203 onMoveThread={moveThread}
204 onRenameThread={renameThread}
203 onRegenerateThreadTitle={regenerateThreadTitle}205 onRegenerateThreadTitle={regenerateThreadTitle}
204 onEnvironmentChange={setSelectedEnvironmentId}206 onEnvironmentChange={setSelectedEnvironmentId}
205 onProjectChange={setSelectedProjectKey}207 onProjectChange={setSelectedProjectKey}
apps/mobile/src/features/home/HomeScreen.tsx+9
...@@ -126,6 +126,7 @@ interface HomeScreenProps {...@@ -126,6 +126,7 @@ interface HomeScreenProps {
126 thread: EnvironmentThreadShell,126 thread: EnvironmentThreadShell,
127 direction: ThreadMoveDestination,127 direction: ThreadMoveDestination,
128 ) => Promise<boolean>;128 ) => Promise<boolean>;
129 readonly onRenameThread: (thread: EnvironmentThreadShell) => void;
129 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;130 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;
130 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;131 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
131 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;132 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
...@@ -538,6 +539,10 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -538,6 +539,10 @@ export function HomeScreen(props: HomeScreenProps) {
538 },539 },
539 [props.onRegenerateThreadTitle],540 [props.onRegenerateThreadTitle],
540 );541 );
542 const handleRenameThread = useCallback(
543 (thread: EnvironmentThreadShell) => props.onRenameThread(thread),
544 [props.onRenameThread],
545 );
541 const handleDeleteThread = props.onDeleteThread;546 const handleDeleteThread = props.onDeleteThread;
542 const handleUnsettleThread = props.onUnsettleThread;547 const handleUnsettleThread = props.onUnsettleThread;
543 // The settled tail renders in pages; expansion resets when the filter548 // The settled tail renders in pages; expansion resets when the filter
...@@ -868,6 +873,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -868,6 +873,7 @@ export function HomeScreen(props: HomeScreenProps) {
868 onSelectThread={props.onSelectThread}873 onSelectThread={props.onSelectThread}
869 onDeleteThread={handleDeleteThread}874 onDeleteThread={handleDeleteThread}
870 onArchiveThread={props.onArchiveThread}875 onArchiveThread={props.onArchiveThread}
876 onRenameThread={handleRenameThread}
871 onRegenerateThreadTitle={handleRegenerateThreadTitle}877 onRegenerateThreadTitle={handleRegenerateThreadTitle}
872 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}878 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
873 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}879 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
...@@ -901,6 +907,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -901,6 +907,7 @@ export function HomeScreen(props: HomeScreenProps) {
901 handleMoveThread,907 handleMoveThread,
902 handlePinThread,908 handlePinThread,
903 handleRegenerateThreadTitle,909 handleRegenerateThreadTitle,
910 handleRenameThread,
904 handleSettleThread,911 handleSettleThread,
905 handleSnoozeThread,912 handleSnoozeThread,
906 handleUnpinThread,913 handleUnpinThread,
...@@ -1025,6 +1032,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1025,6 +1032,7 @@ export function HomeScreen(props: HomeScreenProps) {
1025 searchQuery={props.searchQuery}1032 searchQuery={props.searchQuery}
1026 onArchiveThread={props.onArchiveThread}1033 onArchiveThread={props.onArchiveThread}
1027 onDeleteThread={props.onDeleteThread}1034 onDeleteThread={props.onDeleteThread}
1035 onRenameThread={handleRenameThread}
1028 onRegenerateThreadTitle={handleRegenerateThreadTitle}1036 onRegenerateThreadTitle={handleRegenerateThreadTitle}
1029 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}1037 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
1030 onSelectThread={props.onSelectThread}1038 onSelectThread={props.onSelectThread}
...@@ -1049,6 +1057,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1049,6 +1057,7 @@ export function HomeScreen(props: HomeScreenProps) {
1049 handleSwipeableClose,1057 handleSwipeableClose,
1050 handleSwipeableWillOpen,1058 handleSwipeableWillOpen,
1051 handleRegenerateThreadTitle,1059 handleRegenerateThreadTitle,
1060 handleRenameThread,
1052 machineByEnvironmentId,1061 machineByEnvironmentId,
1053 queuedThreadKeys,1062 queuedThreadKeys,
1054 props.onArchiveThread,1063 props.onArchiveThread,
apps/mobile/src/features/home/useThreadListActions.ts+49-2
...@@ -4,10 +4,10 @@ import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/threa...@@ -4,10 +4,10 @@ import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/threa
4import * as Cause from "effect/Cause";4import * as Cause from "effect/Cause";
5import * as Haptics from "expo-haptics";5import * as Haptics from "expo-haptics";
6import { useCallback, useRef } from "react";6import { useCallback, useRef } from "react";
7import { Alert } from "react-native";7import { Alert, Platform } from "react-native";
88
9import { withThreadDismissal } from "./thread-dismissal";9import { withThreadDismissal } from "./thread-dismissal";
10import { showConfirmDialog } from "../../components/ConfirmDialogHost";10import { showConfirmDialog, showTextInputDialog } from "../../components/ConfirmDialogHost";
11import { scopedThreadKey } from "../../lib/scopedEntities";11import { scopedThreadKey } from "../../lib/scopedEntities";
12import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";12import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
13import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";13import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";
...@@ -27,6 +27,7 @@ import {...@@ -27,6 +27,7 @@ import {
27 threadDropLifecycle,27 threadDropLifecycle,
28} from "../threads/threadOrder";28} from "../threads/threadOrder";
29import { getThreadListV2OrderedSection } from "../threads/threadListV2";29import { getThreadListV2OrderedSection } from "../threads/threadListV2";
30import { resolveThreadTitleRename } from "../threads/thread-title-rename";
3031
31/** Version skew: never send settle/unsettle to a server that predates them32/** Version skew: never send settle/unsettle to a server that predates them
32 (capability defaults false on decode for older servers). */33 (capability defaults false on decode for older servers). */
...@@ -240,6 +241,7 @@ export function useThreadListActions(): {...@@ -240,6 +241,7 @@ export function useThreadListActions(): {
240 thread: EnvironmentThreadShell,241 thread: EnvironmentThreadShell,
241 direction: ThreadMoveDestination,242 direction: ThreadMoveDestination,
242 ) => Promise<boolean>;243 ) => Promise<boolean>;
244 readonly renameThread: (thread: EnvironmentThreadShell) => void;
243 readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;245 readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;
244} {246} {
245 const executeAction = useThreadActionExecutor();247 const executeAction = useThreadActionExecutor();
...@@ -474,6 +476,50 @@ export function useThreadListActions(): {...@@ -474,6 +476,50 @@ export function useThreadListActions(): {
474 },476 },
475 [updateThreadMetadata],477 [updateThreadMetadata],
476 );478 );
479 const renameThread = useCallback(
480 (thread: EnvironmentThreadShell) => {
481 const commit = (title: string) => {
482 const resolution = resolveThreadTitleRename({ title, originalTitle: thread.title });
483 if (resolution.action === "reject-empty") {
484 Alert.alert("Could not rename thread", "Thread title cannot be empty.");
485 return;
486 }
487 if (resolution.action === "noop") return;
488 selectionHaptic();
489 void updateThreadMetadata({
490 environmentId: thread.environmentId,
491 input: { threadId: thread.id, title: resolution.title },
492 }).then((result) => {
493 if (result._tag === "Success") return;
494 const error = Cause.squash(result.cause);
495 Alert.alert(
496 "Could not rename thread",
497 error instanceof Error && error.message.trim().length > 0
498 ? error.message
499 : "The thread could not be renamed.",
500 );
501 });
502 };
503
504 if (Platform.OS === "ios") {
505 Alert.prompt(
506 "Rename thread",
507 undefined,
508 (title) => commit(title ?? ""),
509 "plain-text",
510 thread.title,
511 );
512 return;
513 }
514 showTextInputDialog({
515 title: "Rename thread",
516 initialValue: thread.title,
517 confirmText: "Rename",
518 onConfirm: commit,
519 });
520 },
521 [updateThreadMetadata],
522 );
477523
478 // Plan against the complete section so filtering does not change a move.524 // Plan against the complete section so filtering does not change a move.
479 const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, {525 const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, {
...@@ -653,6 +699,7 @@ export function useThreadListActions(): {...@@ -653,6 +699,7 @@ export function useThreadListActions(): {
653 pinThread,699 pinThread,
654 unpinThread,700 unpinThread,
655 moveThread,701 moveThread,
702 renameThread,
656 regenerateThreadTitle,703 regenerateThreadTitle,
657 };704 };
658}705}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+4
...@@ -171,6 +171,7 @@ function ThreadNavigationSidebarPane(...@@ -171,6 +171,7 @@ function ThreadNavigationSidebarPane(
171 pinThread,171 pinThread,
172 unpinThread,172 unpinThread,
173 moveThread,173 moveThread,
174 renameThread,
174 regenerateThreadTitle,175 regenerateThreadTitle,
175 } = useThreadListActions();176 } = useThreadListActions();
176 const threadListV2Enabled = useThreadListV2Enabled();177 const threadListV2Enabled = useThreadListV2Enabled();
...@@ -932,6 +933,7 @@ function ThreadNavigationSidebarPane(...@@ -932,6 +933,7 @@ function ThreadNavigationSidebarPane(
932 onSelectThread={handleSelectThread}933 onSelectThread={handleSelectThread}
933 onDeleteThread={confirmDeleteThread}934 onDeleteThread={confirmDeleteThread}
934 onArchiveThread={archiveThread}935 onArchiveThread={archiveThread}
936 onRenameThread={renameThread}
935 onRegenerateThreadTitle={regenerateThreadTitle}937 onRegenerateThreadTitle={regenerateThreadTitle}
936 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}938 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
937 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}939 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
...@@ -1049,6 +1051,7 @@ function ThreadNavigationSidebarPane(...@@ -1049,6 +1051,7 @@ function ThreadNavigationSidebarPane(
1049 fullSwipeWidth={props.width - 20}1051 fullSwipeWidth={props.width - 20}
1050 onArchiveThread={archiveThread}1052 onArchiveThread={archiveThread}
1051 onDeleteThread={confirmDeleteThread}1053 onDeleteThread={confirmDeleteThread}
1054 onRenameThread={renameThread}
1052 onRegenerateThreadTitle={regenerateThreadTitle}1055 onRegenerateThreadTitle={regenerateThreadTitle}
1053 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}1056 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
1054 onSelectThread={handleSelectThread}1057 onSelectThread={handleSelectThread}
...@@ -1091,6 +1094,7 @@ function ThreadNavigationSidebarPane(...@@ -1091,6 +1094,7 @@ function ThreadNavigationSidebarPane(
1091 projectByKey,1094 projectByKey,
1092 projectTitleByProjectKey,1095 projectTitleByProjectKey,
1093 regenerateThreadTitle,1096 regenerateThreadTitle,
1097 renameThread,
1094 props.onNewThreadInProject,1098 props.onNewThreadInProject,
1095 props.onNewThreadOnBranch,1099 props.onNewThreadOnBranch,
1096 props.searchQuery,1100 props.searchQuery,
apps/mobile/src/features/threads/thread-list-items.tsx+8-2
...@@ -437,6 +437,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {...@@ -437,6 +437,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
437437
438const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [438const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [
439 { id: "archive", title: "Archive", image: "archivebox" },439 { id: "archive", title: "Archive", image: "archivebox" },
440 { id: "rename", title: "Rename", image: "square.and.pencil" },
440 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },441 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
441];442];
442443
...@@ -458,6 +459,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -458,6 +459,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
458 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;459 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
459 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;460 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
460 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;461 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
462 readonly onRenameThread: (thread: EnvironmentThreadShell) => void;
461 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;463 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;
462 readonly titleRegenerationSupported: boolean;464 readonly titleRegenerationSupported: boolean;
463 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;465 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
...@@ -484,6 +486,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -484,6 +486,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
484 onSelectThread,486 onSelectThread,
485 onArchiveThread,487 onArchiveThread,
486 onDeleteThread,488 onDeleteThread,
489 onRenameThread,
487 onRegenerateThreadTitle,490 onRegenerateThreadTitle,
488 onNewThreadOnBranch,491 onNewThreadOnBranch,
489 } = props;492 } = props;
...@@ -525,6 +528,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -525,6 +528,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
525528
526 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);529 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
527 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);530 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
531 const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]);
528 const handleRegenerateTitle = useCallback(532 const handleRegenerateTitle = useCallback(
529 () => onRegenerateThreadTitle(thread),533 () => onRegenerateThreadTitle(thread),
530 [onRegenerateThreadTitle, thread],534 [onRegenerateThreadTitle, thread],
...@@ -542,11 +546,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -542,11 +546,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
542 ]546 ]
543 : []),547 : []),
544 THREAD_ROW_MENU_ACTIONS[0]!,548 THREAD_ROW_MENU_ACTIONS[0]!,
549 THREAD_ROW_MENU_ACTIONS[1]!,
545 ...buildThreadTitleRegenerationMenuItems({550 ...buildThreadTitleRegenerationMenuItems({
546 supported: props.titleRegenerationSupported,551 supported: props.titleRegenerationSupported,
547 isRegenerating: thread.titleRegeneration != null,552 isRegenerating: thread.titleRegeneration != null,
548 }),553 }),
549 THREAD_ROW_MENU_ACTIONS[1]!,554 THREAD_ROW_MENU_ACTIONS[2]!,
550 ],555 ],
551 [props.titleRegenerationSupported, thread.branch, thread.titleRegeneration],556 [props.titleRegenerationSupported, thread.branch, thread.titleRegeneration],
552 );557 );
...@@ -563,10 +568,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -563,10 +568,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
563 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {568 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
564 if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread);569 if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread);
565 if (nativeEvent.event === "archive") handleArchive();570 if (nativeEvent.event === "archive") handleArchive();
571 if (nativeEvent.event === "rename") handleRename();
566 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();572 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
567 if (nativeEvent.event === "delete") handleDelete();573 if (nativeEvent.event === "delete") handleDelete();
568 },574 },
569 [handleArchive, handleDelete, handleRegenerateTitle, onNewThreadOnBranch, thread],575 [handleArchive, handleDelete, handleRegenerateTitle, handleRename, onNewThreadOnBranch, thread],
570 );576 );
571577
572 const statusPill = effectiveStatus ? (578 const statusPill = effectiveStatus ? (
apps/mobile/src/features/threads/thread-list-v2-items.tsx+20-13
...@@ -382,6 +382,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -382,6 +382,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
382 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;382 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
383 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;383 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
384 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;384 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
385 readonly onRenameThread: (thread: EnvironmentThreadShell) => void;
385 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;386 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;
386 readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;387 readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
387 readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void;388 readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void;
...@@ -423,6 +424,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -423,6 +424,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
423 variant,424 variant,
424 onSelectThread,425 onSelectThread,
425 onDeleteThread,426 onDeleteThread,
427 onRenameThread,
426 onRegenerateThreadTitle,428 onRegenerateThreadTitle,
427 onNewThreadOnBranch,429 onNewThreadOnBranch,
428 onSettleThread,430 onSettleThread,
...@@ -468,6 +470,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -468,6 +470,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
468 settledTimestamp !== null ? relativeTime(settledTimestamp) : threadTimeLabel(thread);470 settledTimestamp !== null ? relativeTime(settledTimestamp) : threadTimeLabel(thread);
469471
470 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);472 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
473 const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]);
471 const handleRegenerateTitle = useCallback(474 const handleRegenerateTitle = useCallback(
472 () => onRegenerateThreadTitle(thread),475 () => onRegenerateThreadTitle(thread),
473 [onRegenerateThreadTitle, thread],476 [onRegenerateThreadTitle, thread],
...@@ -560,12 +563,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -560,12 +563,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
560 variant,563 variant,
561 ],564 ],
562 );565 );
563 const titleRegenerationMenuItems = useMemo<MenuAction[]>(566 const titleMenuItems = useMemo<MenuAction[]>(
564 () =>567 () => [
565 buildThreadTitleRegenerationMenuItems({568 { id: "rename", title: "Rename", image: "square.and.pencil" },
569 ...buildThreadTitleRegenerationMenuItems({
566 supported: props.titleRegenerationSupported,570 supported: props.titleRegenerationSupported,
567 isRegenerating: thread.titleRegeneration != null,571 isRegenerating: thread.titleRegeneration != null,
568 }),572 }),
573 ],
569 [props.titleRegenerationSupported, thread.titleRegeneration],574 [props.titleRegenerationSupported, thread.titleRegeneration],
570 );575 );
571 const snoozableCardMenuActions = useMemo<MenuAction[]>(576 const snoozableCardMenuActions = useMemo<MenuAction[]>(
...@@ -578,19 +583,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -578,19 +583,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
578 subactions: snoozePresetActions,583 subactions: snoozePresetActions,
579 },584 },
580 ...arrangementMenuItems,585 ...arrangementMenuItems,
581 ...titleRegenerationMenuItems,586 ...titleMenuItems,
582 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },587 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
583 ],588 ],
584 [arrangementMenuItems, snoozePresetActions, titleRegenerationMenuItems],589 [arrangementMenuItems, snoozePresetActions, titleMenuItems],
585 );590 );
586 const cardMenuActions = useMemo<MenuAction[]>(591 const cardMenuActions = useMemo<MenuAction[]>(
587 () => [592 () => [
588 CARD_MENU_ACTIONS[0]!,593 CARD_MENU_ACTIONS[0]!,
589 ...arrangementMenuItems,594 ...arrangementMenuItems,
590 ...titleRegenerationMenuItems,595 ...titleMenuItems,
591 ...CARD_MENU_ACTIONS.slice(1),596 ...CARD_MENU_ACTIONS.slice(1),
592 ],597 ],
593 [arrangementMenuItems, titleRegenerationMenuItems],598 [arrangementMenuItems, titleMenuItems],
594 );599 );
595 const slimMenuActions = useMemo<MenuAction[]>(600 const slimMenuActions = useMemo<MenuAction[]>(
596 () => [601 () => [
...@@ -598,23 +603,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -598,23 +603,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
598 ...arrangementMenuItems.filter(603 ...arrangementMenuItems.filter(
599 (action) => action.id !== "move-up" && action.id !== "move-down",604 (action) => action.id !== "move-up" && action.id !== "move-down",
600 ),605 ),
601 ...titleRegenerationMenuItems,606 ...titleMenuItems,
602 SLIM_MENU_ACTIONS[1]!,607 SLIM_MENU_ACTIONS[1]!,
603 ],608 ],
604 [arrangementMenuItems, titleRegenerationMenuItems],609 [arrangementMenuItems, titleMenuItems],
605 );610 );
606 const snoozedMenuActions = useMemo<MenuAction[]>(611 const snoozedMenuActions = useMemo<MenuAction[]>(
607 () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!],612 () => [SNOOZED_MENU_ACTIONS[0]!, ...titleMenuItems, SNOOZED_MENU_ACTIONS[1]!],
608 [titleRegenerationMenuItems],613 [titleMenuItems],
609 );614 );
610 const legacyMenuActions = useMemo<MenuAction[]>(615 const legacyMenuActions = useMemo<MenuAction[]>(
611 () => [616 () => [
612 LEGACY_MENU_ACTIONS[0]!,617 LEGACY_MENU_ACTIONS[0]!,
613 ...arrangementMenuItems,618 ...arrangementMenuItems,
614 ...titleRegenerationMenuItems,619 ...titleMenuItems,
615 LEGACY_MENU_ACTIONS[1]!,620 LEGACY_MENU_ACTIONS[1]!,
616 ],621 ],
617 [arrangementMenuItems, titleRegenerationMenuItems],622 [arrangementMenuItems, titleMenuItems],
618 );623 );
619 const handleMenuAction = useCallback(624 const handleMenuAction = useCallback(
620 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {625 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
...@@ -628,6 +633,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -628,6 +633,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
628 if (nativeEvent.event === "move-up") handleMoveUp();633 if (nativeEvent.event === "move-up") handleMoveUp();
629 if (nativeEvent.event === "move-down") handleMoveDown();634 if (nativeEvent.event === "move-down") handleMoveDown();
630 if (nativeEvent.event === "archive") handleArchive();635 if (nativeEvent.event === "archive") handleArchive();
636 if (nativeEvent.event === "rename") handleRename();
631 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();637 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
632 if (nativeEvent.event === "delete") handleDelete();638 if (nativeEvent.event === "delete") handleDelete();
633 if (nativeEvent.event === "snooze:custom") {639 if (nativeEvent.event === "snooze:custom") {
...@@ -651,6 +657,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -651,6 +657,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
651 handleArchive,657 handleArchive,
652 handleDelete,658 handleDelete,
653 handleRegenerateTitle,659 handleRegenerateTitle,
660 handleRename,
654 handleMoveDown,661 handleMoveDown,
655 handleMoveUp,662 handleMoveUp,
656 handlePin,663 handlePin,
apps/mobile/src/features/threads/thread-title-rename.test.ts created+21
...@@ -0,0 +1,21 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveThreadTitleRename } from "./thread-title-rename";
4
5describe("resolveThreadTitleRename", () => {
6 it("trims a changed title", () => {
7 expect(resolveThreadTitleRename({ title: " New title ", originalTitle: "Old" })).toEqual({
8 action: "rename",
9 title: "New title",
10 });
11 });
12
13 it("rejects empty and unchanged titles", () => {
14 expect(resolveThreadTitleRename({ title: " ", originalTitle: "Old" })).toEqual({
15 action: "reject-empty",
16 });
17 expect(resolveThreadTitleRename({ title: " Old ", originalTitle: "Old" })).toEqual({
18 action: "noop",
19 });
20 });
21});
apps/mobile/src/features/threads/thread-title-rename.ts created+14
...@@ -0,0 +1,14 @@
1export type ThreadTitleRenameResolution =
2 | { readonly action: "rename"; readonly title: string }
3 | { readonly action: "noop" }
4 | { readonly action: "reject-empty" };
5
6export function resolveThreadTitleRename(input: {
7 readonly title: string;
8 readonly originalTitle: string;
9}): ThreadTitleRenameResolution {
10 const title = input.title.trim();
11 if (title.length === 0) return { action: "reject-empty" };
12 if (title === input.originalTitle) return { action: "noop" };
13 return { action: "rename", title };
14}