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