| 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,5 +1,5 @@ |
| 1 | import { useCallback, useEffect, useState } from "react"; | 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 | import { cn } from "../lib/cn"; | 4 | import { cn } from "../lib/cn"; |
| 5 | import { AppText } from "./AppText"; | 5 | import { 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 | }; |
| 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 | * Imperative confirm dialog, Alert.alert-shaped. Native iOS alerts already | 33 | * 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 | */ |
| 25 | export function showConfirmDialog(request: ConfirmDialogRequest): void { | 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,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 | */ |
| 35 | export function ConfirmDialogHost() { | 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 | 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 | }, []); |
| 43 | 64 | ||
| 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]); |
| 48 | 69 | ||
| 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; | ||
| 53 | 80 | ||
| 54 | return ( | 81 | return ( |
| 55 | <Modal | 82 | <Modal |
| 56 | visible={request !== null} | 83 | visible={presented !== null} |
| 57 | transparent | 84 | transparent |
| 58 | animationType="fade" | 85 | animationType="fade" |
| 59 | statusBarTranslucent | 86 | statusBarTranslucent |
| 60 | navigationBarTranslucent | 87 | 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 | <Pressable | 113 | <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 | <Pressable | 124 | <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 | <AppText | 130 | <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 filter | 548 | // 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 |
| 4 | import * as Cause from "effect/Cause"; | 4 | import * as Cause from "effect/Cause"; |
| 5 | import * as Haptics from "expo-haptics"; | 5 | import * as Haptics from "expo-haptics"; |
| 6 | import { useCallback, useRef } from "react"; | 6 | import { useCallback, useRef } from "react"; |
| 7 | import { Alert } from "react-native"; | 7 | import { Alert, Platform } from "react-native"; |
| 8 | 8 | ||
| 9 | import { withThreadDismissal } from "./thread-dismissal"; | 9 | import { withThreadDismissal } from "./thread-dismissal"; |
| 10 | import { showConfirmDialog } from "../../components/ConfirmDialogHost"; | 10 | import { showConfirmDialog, showTextInputDialog } from "../../components/ConfirmDialogHost"; |
| 11 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 11 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 12 | import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; | 12 | import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; |
| 13 | import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; | 13 | import { 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"; |
| 29 | import { getThreadListV2OrderedSection } from "../threads/threadListV2"; | 29 | import { getThreadListV2OrderedSection } from "../threads/threadListV2"; |
| 30 | import { resolveThreadTitleRename } from "../threads/thread-title-rename"; | ||
| 30 | 31 | ||
| 31 | /** Version skew: never send settle/unsettle to a server that predates them | 32 | /** 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 | ); | ||
| 477 | 523 | ||
| 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: { |
| 437 | 437 | ||
| 438 | const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [ | 438 | const 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 | ]; |
| 442 | 443 | ||
| ... | @@ -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: { |
| 525 | 528 | ||
| 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 | ); |
| 571 | 577 | ||
| 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); |
| 469 | 471 | ||
| 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 @@ | ||
| 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 | } | ||