| author | |
| committer | |
| log | d37a9b09b8ea6b014e9ea2d6b00d9b25cde0efc6 |
| tree | ccca892469cc49265734449f6ba21f3133add7f1 |
| parent | 560d4a4560ddb5f42c8f8e0e35fa7827c0e46f80 |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 223 insertions(+), 11 deletions(-)
apps/mobile/src/features/home/HomeRouteScreen.tsx+2| ... | @@ -45,6 +45,7 @@ export function HomeRouteScreen() { | ... | @@ -45,6 +45,7 @@ export function HomeRouteScreen() { |
| 45 | pinThread, | 45 | pinThread, |
| 46 | unpinThread, | 46 | unpinThread, |
| 47 | movePinnedThread, | 47 | movePinnedThread, |
| 48 | regenerateThreadTitle, | ||
| 48 | unsettleThread, | 49 | unsettleThread, |
| 49 | } = useThreadListActions(); | 50 | } = useThreadListActions(); |
| 50 | const pendingTasks = usePendingNewTasks(); | 51 | const pendingTasks = usePendingNewTasks(); |
| ... | @@ -186,6 +187,7 @@ export function HomeRouteScreen() { | ... | @@ -186,6 +187,7 @@ export function HomeRouteScreen() { |
| 186 | onPinThread={pinThread} | 187 | onPinThread={pinThread} |
| 187 | onUnpinThread={unpinThread} | 188 | onUnpinThread={unpinThread} |
| 188 | onMovePinnedThread={movePinnedThread} | 189 | onMovePinnedThread={movePinnedThread} |
| 190 | onRegenerateThreadTitle={regenerateThreadTitle} | ||
| 189 | onEnvironmentChange={setSelectedEnvironmentId} | 191 | onEnvironmentChange={setSelectedEnvironmentId} |
| 190 | onProjectChange={setSelectedProjectKey} | 192 | onProjectChange={setSelectedProjectKey} |
| 191 | onOpenSettings={() => | 193 | onOpenSettings={() => |
apps/mobile/src/features/home/HomeScreen.tsx+24| ... | @@ -115,6 +115,7 @@ interface HomeScreenProps { | ... | @@ -115,6 +115,7 @@ interface HomeScreenProps { |
| 115 | thread: EnvironmentThreadShell, | 115 | thread: EnvironmentThreadShell, |
| 116 | direction: "up" | "down", | 116 | direction: "up" | "down", |
| 117 | ) => Promise<boolean>; | 117 | ) => Promise<boolean>; |
| 118 | readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>; | ||
| 118 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; | 119 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; |
| 119 | readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; | 120 | readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; |
| 120 | readonly onNewThreadInProject: (project: EnvironmentProject) => void; | 121 | readonly onNewThreadInProject: (project: EnvironmentProject) => void; |
| ... | @@ -538,6 +539,12 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -538,6 +539,12 @@ export function HomeScreen(props: HomeScreenProps) { |
| 538 | }, | 539 | }, |
| 539 | [props.onUnpinThread], | 540 | [props.onUnpinThread], |
| 540 | ); | 541 | ); |
| 542 | const handleRegenerateThreadTitle = useCallback( | ||
| 543 | (thread: EnvironmentThreadShell) => { | ||
| 544 | void props.onRegenerateThreadTitle(thread); | ||
| 545 | }, | ||
| 546 | [props.onRegenerateThreadTitle], | ||
| 547 | ); | ||
| 541 | const handleDeleteThread = props.onDeleteThread; | 548 | const handleDeleteThread = props.onDeleteThread; |
| 542 | const handleUnsettleThread = props.onUnsettleThread; | 549 | const handleUnsettleThread = props.onUnsettleThread; |
| 543 | // The settled tail renders in pages; expansion resets when the filter | 550 | // The settled tail renders in pages; expansion resets when the filter |
| ... | @@ -615,6 +622,15 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -615,6 +622,15 @@ export function HomeScreen(props: HomeScreenProps) { |
| 615 | } | 622 | } |
| 616 | return supported; | 623 | return supported; |
| 617 | }, [serverConfigs]); | 624 | }, [serverConfigs]); |
| 625 | const titleRegenerationEnvironmentIds = useMemo(() => { | ||
| 626 | const supported = new Set<EnvironmentId>(); | ||
| 627 | for (const [environmentId, config] of serverConfigs) { | ||
| 628 | if (config.environment.capabilities.threadTitleRegeneration === true) { | ||
| 629 | supported.add(environmentId); | ||
| 630 | } | ||
| 631 | } | ||
| 632 | return supported; | ||
| 633 | }, [serverConfigs]); | ||
| 618 | // Canonical arranged pinned order (reorder-capable threads only) for the | 634 | // Canonical arranged pinned order (reorder-capable threads only) for the |
| 619 | // Move up/down position flags. Computed from all shells, not the rendered | 635 | // Move up/down position flags. Computed from all shells, not the rendered |
| 620 | // list, so search/scope filtering never disables or misdirects a move. | 636 | // list, so search/scope filtering never disables or misdirects a move. |
| ... | @@ -811,6 +827,8 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -811,6 +827,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 811 | onSelectThread={props.onSelectThread} | 827 | onSelectThread={props.onSelectThread} |
| 812 | onDeleteThread={handleDeleteThread} | 828 | onDeleteThread={handleDeleteThread} |
| 813 | onArchiveThread={props.onArchiveThread} | 829 | onArchiveThread={props.onArchiveThread} |
| 830 | onRegenerateThreadTitle={handleRegenerateThreadTitle} | ||
| 831 | titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} | ||
| 814 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} | 832 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} |
| 815 | onSettleThread={handleSettleThread} | 833 | onSettleThread={handleSettleThread} |
| 816 | snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} | 834 | snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} |
| ... | @@ -842,6 +860,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -842,6 +860,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 842 | arrangedPinnedKeys, | 860 | arrangedPinnedKeys, |
| 843 | handleMovePinnedThread, | 861 | handleMovePinnedThread, |
| 844 | handlePinThread, | 862 | handlePinThread, |
| 863 | handleRegenerateThreadTitle, | ||
| 845 | handleSettleThread, | 864 | handleSettleThread, |
| 846 | handleSnoozeThread, | 865 | handleSnoozeThread, |
| 847 | handleUnpinThread, | 866 | handleUnpinThread, |
| ... | @@ -863,6 +882,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -863,6 +882,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 863 | snoozeEnvironmentIds, | 882 | snoozeEnvironmentIds, |
| 864 | threadListV2Items, | 883 | threadListV2Items, |
| 865 | threadSearchMatchByKey, | 884 | threadSearchMatchByKey, |
| 885 | titleRegenerationEnvironmentIds, | ||
| 866 | toggleSettledShelf, | 886 | toggleSettledShelf, |
| 867 | toggleSnoozedShelf, | 887 | toggleSnoozedShelf, |
| 868 | v2ProjectTitleByProjectKey, | 888 | v2ProjectTitleByProjectKey, |
| ... | @@ -967,6 +987,8 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -967,6 +987,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 967 | searchQuery={props.searchQuery} | 987 | searchQuery={props.searchQuery} |
| 968 | onArchiveThread={props.onArchiveThread} | 988 | onArchiveThread={props.onArchiveThread} |
| 969 | onDeleteThread={props.onDeleteThread} | 989 | onDeleteThread={props.onDeleteThread} |
| 990 | onRegenerateThreadTitle={handleRegenerateThreadTitle} | ||
| 991 | titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} | ||
| 970 | onSelectThread={props.onSelectThread} | 992 | onSelectThread={props.onSelectThread} |
| 971 | onSwipeableClose={handleSwipeableClose} | 993 | onSwipeableClose={handleSwipeableClose} |
| 972 | onSwipeableWillOpen={handleSwipeableWillOpen} | 994 | onSwipeableWillOpen={handleSwipeableWillOpen} |
| ... | @@ -988,6 +1010,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -988,6 +1010,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 988 | [ | 1010 | [ |
| 989 | handleSwipeableClose, | 1011 | handleSwipeableClose, |
| 990 | handleSwipeableWillOpen, | 1012 | handleSwipeableWillOpen, |
| 1013 | handleRegenerateThreadTitle, | ||
| 991 | projectCwdByKey, | 1014 | projectCwdByKey, |
| 992 | props.onArchiveThread, | 1015 | props.onArchiveThread, |
| 993 | props.onDeletePendingTask, | 1016 | props.onDeletePendingTask, |
| ... | @@ -998,6 +1021,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -998,6 +1021,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 998 | props.searchQuery, | 1021 | props.searchQuery, |
| 999 | props.savedConnectionsById, | 1022 | props.savedConnectionsById, |
| 1000 | threadSearchMatchByKey, | 1023 | threadSearchMatchByKey, |
| 1024 | titleRegenerationEnvironmentIds, | ||
| 1001 | updateGroupDisplay, | 1025 | updateGroupDisplay, |
| 1002 | ], | 1026 | ], |
| 1003 | ); | 1027 | ); |
apps/mobile/src/features/home/useThreadListActions.ts+56| ... | @@ -48,6 +48,15 @@ function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["en | ... | @@ -48,6 +48,15 @@ function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["en |
| 48 | ); | 48 | ); |
| 49 | } | 49 | } |
| 50 | 50 | ||
| 51 | function environmentSupportsTitleRegeneration( | ||
| 52 | environmentId: EnvironmentThreadShell["environmentId"], | ||
| 53 | ) { | ||
| 54 | return ( | ||
| 55 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | ||
| 56 | .threadTitleRegeneration === true | ||
| 57 | ); | ||
| 58 | } | ||
| 59 | |||
| 51 | type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; | 60 | type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; |
| 52 | 61 | ||
| 53 | const ACTION_VERBS: Record<ThreadListAction, string> = { | 62 | const ACTION_VERBS: Record<ThreadListAction, string> = { |
| ... | @@ -227,13 +236,18 @@ export function useThreadListActions(): { | ... | @@ -227,13 +236,18 @@ export function useThreadListActions(): { |
| 227 | thread: EnvironmentThreadShell, | 236 | thread: EnvironmentThreadShell, |
| 228 | direction: "up" | "down", | 237 | direction: "up" | "down", |
| 229 | ) => Promise<boolean>; | 238 | ) => Promise<boolean>; |
| 239 | readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>; | ||
| 230 | } { | 240 | } { |
| 231 | const executeAction = useThreadActionExecutor(); | 241 | const executeAction = useThreadActionExecutor(); |
| 232 | const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); | 242 | const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); |
| 233 | const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); | 243 | const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); |
| 234 | const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false }); | 244 | const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false }); |
| 235 | const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false }); | 245 | const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false }); |
| 246 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | ||
| 247 | reportFailure: false, | ||
| 248 | }); | ||
| 236 | const snoozeInFlightThreadKeys = useRef(new Set<string>()); | 249 | const snoozeInFlightThreadKeys = useRef(new Set<string>()); |
| 250 | const titleRegenerationInFlightThreadKeys = useRef(new Set<string>()); | ||
| 237 | 251 | ||
| 238 | const archiveThread = useCallback( | 252 | const archiveThread = useCallback( |
| 239 | (thread: EnvironmentThreadShell) => { | 253 | (thread: EnvironmentThreadShell) => { |
| ... | @@ -405,6 +419,47 @@ export function useThreadListActions(): { | ... | @@ -405,6 +419,47 @@ export function useThreadListActions(): { |
| 405 | }, | 419 | }, |
| 406 | [unpinMutation], | 420 | [unpinMutation], |
| 407 | ); | 421 | ); |
| 422 | const regenerateThreadTitle = useCallback( | ||
| 423 | async (thread: EnvironmentThreadShell) => { | ||
| 424 | const key = scopedThreadKey(thread.environmentId, thread.id); | ||
| 425 | if ( | ||
| 426 | thread.titleRegeneration != null || | ||
| 427 | titleRegenerationInFlightThreadKeys.current.has(key) | ||
| 428 | ) { | ||
| 429 | return false; | ||
| 430 | } | ||
| 431 | if (!environmentSupportsTitleRegeneration(thread.environmentId)) { | ||
| 432 | Alert.alert( | ||
| 433 | "Could not regenerate title", | ||
| 434 | "This environment's server does not support title regeneration yet. Update the server to regenerate thread titles.", | ||
| 435 | ); | ||
| 436 | return false; | ||
| 437 | } | ||
| 438 | |||
| 439 | titleRegenerationInFlightThreadKeys.current.add(key); | ||
| 440 | selectionHaptic(); | ||
| 441 | try { | ||
| 442 | const result = await updateThreadMetadata({ | ||
| 443 | environmentId: thread.environmentId, | ||
| 444 | input: { threadId: thread.id, regenerateTitle: true }, | ||
| 445 | }); | ||
| 446 | if (result._tag === "Failure") { | ||
| 447 | const error = Cause.squash(result.cause); | ||
| 448 | Alert.alert( | ||
| 449 | "Could not regenerate title", | ||
| 450 | error instanceof Error && error.message.trim().length > 0 | ||
| 451 | ? error.message | ||
| 452 | : "The thread title could not be regenerated.", | ||
| 453 | ); | ||
| 454 | return false; | ||
| 455 | } | ||
| 456 | return true; | ||
| 457 | } finally { | ||
| 458 | titleRegenerationInFlightThreadKeys.current.delete(key); | ||
| 459 | } | ||
| 460 | }, | ||
| 461 | [updateThreadMetadata], | ||
| 462 | ); | ||
| 408 | 463 | ||
| 409 | // Move up / Move down for the pinned block. Computed against the CANONICAL | 464 | // Move up / Move down for the pinned block. Computed against the CANONICAL |
| 410 | // keyed pinned order (not the rendered list), so the move is valid even | 465 | // keyed pinned order (not the rendered list), so the move is valid even |
| ... | @@ -497,6 +552,7 @@ export function useThreadListActions(): { | ... | @@ -497,6 +552,7 @@ export function useThreadListActions(): { |
| 497 | pinThread, | 552 | pinThread, |
| 498 | unpinThread, | 553 | unpinThread, |
| 499 | movePinnedThread, | 554 | movePinnedThread, |
| 555 | regenerateThreadTitle, | ||
| 500 | }; | 556 | }; |
| 501 | } | 557 | } |
| 502 | 558 |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+16| ... | @@ -211,6 +211,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -211,6 +211,7 @@ function ThreadNavigationSidebarPane( |
| 211 | pinThread, | 211 | pinThread, |
| 212 | unpinThread, | 212 | unpinThread, |
| 213 | movePinnedThread, | 213 | movePinnedThread, |
| 214 | regenerateThreadTitle, | ||
| 214 | } = useThreadListActions(); | 215 | } = useThreadListActions(); |
| 215 | const threadListV2Enabled = useThreadListV2Enabled(); | 216 | const threadListV2Enabled = useThreadListV2Enabled(); |
| 216 | const pendingTasks = usePendingNewTasks(); | 217 | const pendingTasks = usePendingNewTasks(); |
| ... | @@ -505,6 +506,15 @@ function ThreadNavigationSidebarPane( | ... | @@ -505,6 +506,15 @@ function ThreadNavigationSidebarPane( |
| 505 | } | 506 | } |
| 506 | return supported; | 507 | return supported; |
| 507 | }, [serverConfigs]); | 508 | }, [serverConfigs]); |
| 509 | const titleRegenerationEnvironmentIds = useMemo(() => { | ||
| 510 | const supported = new Set<EnvironmentId>(); | ||
| 511 | for (const [environmentId, config] of serverConfigs) { | ||
| 512 | if (config.environment.capabilities.threadTitleRegeneration === true) { | ||
| 513 | supported.add(environmentId); | ||
| 514 | } | ||
| 515 | } | ||
| 516 | return supported; | ||
| 517 | }, [serverConfigs]); | ||
| 508 | // Canonical arranged pinned order for Move up/down flags — computed from | 518 | // Canonical arranged pinned order for Move up/down flags — computed from |
| 509 | // all shells so search/scope filtering never disables a valid move. | 519 | // all shells so search/scope filtering never disables a valid move. |
| 510 | const arrangedPinnedKeys = useMemo(() => { | 520 | const arrangedPinnedKeys = useMemo(() => { |
| ... | @@ -950,6 +960,8 @@ function ThreadNavigationSidebarPane( | ... | @@ -950,6 +960,8 @@ function ThreadNavigationSidebarPane( |
| 950 | onSelectThread={handleSelectThread} | 960 | onSelectThread={handleSelectThread} |
| 951 | onDeleteThread={confirmDeleteThread} | 961 | onDeleteThread={confirmDeleteThread} |
| 952 | onArchiveThread={archiveThread} | 962 | onArchiveThread={archiveThread} |
| 963 | onRegenerateThreadTitle={regenerateThreadTitle} | ||
| 964 | titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} | ||
| 953 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} | 965 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} |
| 954 | onSettleThread={settleThread} | 966 | onSettleThread={settleThread} |
| 955 | snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} | 967 | snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} |
| ... | @@ -1067,6 +1079,8 @@ function ThreadNavigationSidebarPane( | ... | @@ -1067,6 +1079,8 @@ function ThreadNavigationSidebarPane( |
| 1067 | fullSwipeWidth={props.width - 20} | 1079 | fullSwipeWidth={props.width - 20} |
| 1068 | onArchiveThread={archiveThread} | 1080 | onArchiveThread={archiveThread} |
| 1069 | onDeleteThread={confirmDeleteThread} | 1081 | onDeleteThread={confirmDeleteThread} |
| 1082 | onRegenerateThreadTitle={regenerateThreadTitle} | ||
| 1083 | titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} | ||
| 1070 | onSelectThread={handleSelectThread} | 1084 | onSelectThread={handleSelectThread} |
| 1071 | onSwipeableClose={handleSwipeableClose} | 1085 | onSwipeableClose={handleSwipeableClose} |
| 1072 | onSwipeableWillOpen={handleSwipeableWillOpen} | 1086 | onSwipeableWillOpen={handleSwipeableWillOpen} |
| ... | @@ -1103,6 +1117,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -1103,6 +1117,7 @@ function ThreadNavigationSidebarPane( |
| 1103 | projectByKey, | 1117 | projectByKey, |
| 1104 | projectCwdByKey, | 1118 | projectCwdByKey, |
| 1105 | projectTitleByProjectKey, | 1119 | projectTitleByProjectKey, |
| 1120 | regenerateThreadTitle, | ||
| 1106 | props.onNewThreadInProject, | 1121 | props.onNewThreadInProject, |
| 1107 | props.searchQuery, | 1122 | props.searchQuery, |
| 1108 | props.selectedThreadKey, | 1123 | props.selectedThreadKey, |
| ... | @@ -1110,6 +1125,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -1110,6 +1125,7 @@ function ThreadNavigationSidebarPane( |
| 1110 | savedConnectionsById, | 1125 | savedConnectionsById, |
| 1111 | serverConfigs, | 1126 | serverConfigs, |
| 1112 | threadSearchMatchByKey, | 1127 | threadSearchMatchByKey, |
| 1128 | titleRegenerationEnvironmentIds, | ||
| 1113 | settleThread, | 1129 | settleThread, |
| 1114 | settlementEnvironmentIds, | 1130 | settlementEnvironmentIds, |
| 1115 | showMoreSettled, | 1131 | showMoreSettled, |
apps/mobile/src/features/threads/thread-list-items.tsx+23-3| ... | @@ -22,6 +22,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | ... | @@ -22,6 +22,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 22 | import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; | 22 | import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; |
| 23 | import type { HomeGroupDisplayAction } from "../home/homeListItems"; | 23 | import type { HomeGroupDisplayAction } from "../home/homeListItems"; |
| 24 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; | 24 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; |
| 25 | import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; | ||
| 25 | import { resolveThreadStatus } from "./threadPresentation"; | 26 | import { resolveThreadStatus } from "./threadPresentation"; |
| 26 | import { ThreadSearchMatchExcerpt } from "./thread-search-match"; | 27 | import { ThreadSearchMatchExcerpt } from "./thread-search-match"; |
| 27 | 28 | ||
| ... | @@ -430,6 +431,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -430,6 +431,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 430 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; | 431 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; |
| 431 | readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; | 432 | readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; |
| 432 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; | 433 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; |
| 434 | readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; | ||
| 435 | readonly titleRegenerationSupported: boolean; | ||
| 433 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; | 436 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; |
| 434 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; | 437 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; |
| 435 | readonly simultaneousSwipeGesture?: ComponentProps< | 438 | readonly simultaneousSwipeGesture?: ComponentProps< |
| ... | @@ -451,7 +454,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -451,7 +454,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 451 | const pressedBackgroundColor = useThemeColor("--color-subtle"); | 454 | const pressedBackgroundColor = useThemeColor("--color-subtle"); |
| 452 | const selectedBackgroundColor = useThemeColor("--color-user-bubble"); | 455 | const selectedBackgroundColor = useThemeColor("--color-user-bubble"); |
| 453 | 456 | ||
| 454 | const { thread, onSelectThread, onArchiveThread, onDeleteThread } = props; | 457 | const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } = |
| 458 | props; | ||
| 455 | const status = resolveThreadStatus(thread); | 459 | const status = resolveThreadStatus(thread); |
| 456 | const pr = useThreadPr(thread, props.projectCwd); | 460 | const pr = useThreadPr(thread, props.projectCwd); |
| 457 | const timestamp = relativeTime( | 461 | const timestamp = relativeTime( |
| ... | @@ -471,6 +475,21 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -471,6 +475,21 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 471 | 475 | ||
| 472 | const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); | 476 | const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); |
| 473 | const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); | 477 | const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); |
| 478 | const handleRegenerateTitle = useCallback( | ||
| 479 | () => onRegenerateThreadTitle(thread), | ||
| 480 | [onRegenerateThreadTitle, thread], | ||
| 481 | ); | ||
| 482 | const menuActions = useMemo<MenuAction[]>( | ||
| 483 | () => [ | ||
| 484 | THREAD_ROW_MENU_ACTIONS[0]!, | ||
| 485 | ...buildThreadTitleRegenerationMenuItems({ | ||
| 486 | supported: props.titleRegenerationSupported, | ||
| 487 | isRegenerating: thread.titleRegeneration != null, | ||
| 488 | }), | ||
| 489 | THREAD_ROW_MENU_ACTIONS[1]!, | ||
| 490 | ], | ||
| 491 | [props.titleRegenerationSupported, thread.titleRegeneration], | ||
| 492 | ); | ||
| 474 | const primaryAction = useMemo( | 493 | const primaryAction = useMemo( |
| 475 | () => ({ | 494 | () => ({ |
| 476 | accessibilityLabel: `Archive ${thread.title}`, | 495 | accessibilityLabel: `Archive ${thread.title}`, |
| ... | @@ -483,9 +502,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -483,9 +502,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 483 | const handleMenuAction = useCallback( | 502 | const handleMenuAction = useCallback( |
| 484 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { | 503 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { |
| 485 | if (nativeEvent.event === "archive") handleArchive(); | 504 | if (nativeEvent.event === "archive") handleArchive(); |
| 505 | if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); | ||
| 486 | if (nativeEvent.event === "delete") handleDelete(); | 506 | if (nativeEvent.event === "delete") handleDelete(); |
| 487 | }, | 507 | }, |
| 488 | [handleArchive, handleDelete], | 508 | [handleArchive, handleDelete, handleRegenerateTitle], |
| 489 | ); | 509 | ); |
| 490 | 510 | ||
| 491 | const statusPill = effectiveStatus ? ( | 511 | const statusPill = effectiveStatus ? ( |
| ... | @@ -674,7 +694,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -674,7 +694,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 674 | // ControlPillMenu injects onLongPress into the row and anchors the | 694 | // ControlPillMenu injects onLongPress into the row and anchors the |
| 675 | // token-styled dropdown to it; taps and swipes are untouched. | 695 | // token-styled dropdown to it; taps and swipes are untouched. |
| 676 | <ControlPillMenu | 696 | <ControlPillMenu |
| 677 | actions={THREAD_ROW_MENU_ACTIONS} | 697 | actions={menuActions} |
| 678 | onPressAction={handleMenuAction} | 698 | onPressAction={handleMenuAction} |
| 679 | shouldOpenOnLongPress | 699 | shouldOpenOnLongPress |
| 680 | > | 700 | > |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+45-8| ... | @@ -27,6 +27,7 @@ import { useThemeColor } from "../../lib/useThemeColor"; | ... | @@ -27,6 +27,7 @@ import { useThemeColor } from "../../lib/useThemeColor"; |
| 27 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 27 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 28 | import { useThreadPr } from "../../state/use-thread-pr"; | 28 | import { useThreadPr } from "../../state/use-thread-pr"; |
| 29 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; | 29 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; |
| 30 | import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; | ||
| 30 | import { | 31 | import { |
| 31 | resolveThreadListV2SnoozeMenuSelection, | 32 | resolveThreadListV2SnoozeMenuSelection, |
| 32 | resolveThreadListV2SnoozeGateExpiryMs, | 33 | resolveThreadListV2SnoozeGateExpiryMs, |
| ... | @@ -65,8 +66,8 @@ function threadTimeLabel(thread: EnvironmentThreadShell): string { | ... | @@ -65,8 +66,8 @@ function threadTimeLabel(thread: EnvironmentThreadShell): string { |
| 65 | return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt); | 66 | return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt); |
| 66 | } | 67 | } |
| 67 | 68 | ||
| 68 | // Menus stay lifecycle-focused: settle/un-settle plus delete. Archive keeps | 69 | // Menus keep lifecycle and title regeneration together. Archive keeps its |
| 69 | // its own surface (thread screen / settings) rather than crowding the row. | 70 | // own surface (thread screen / settings) rather than crowding v2 rows. |
| 70 | const CARD_MENU_ACTIONS: MenuAction[] = [ | 71 | const CARD_MENU_ACTIONS: MenuAction[] = [ |
| 71 | { id: "settle", title: "Settle", image: "checkmark" }, | 72 | { id: "settle", title: "Settle", image: "checkmark" }, |
| 72 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, | 73 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, |
| ... | @@ -341,6 +342,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -341,6 +342,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 341 | readonly fullSwipeWidth?: number; | 342 | readonly fullSwipeWidth?: number; |
| 342 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; | 343 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; |
| 343 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; | 344 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; |
| 345 | readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; | ||
| 344 | readonly onSettleThread: (thread: EnvironmentThreadShell) => void; | 346 | readonly onSettleThread: (thread: EnvironmentThreadShell) => void; |
| 345 | readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void; | 347 | readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void; |
| 346 | readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void; | 348 | readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void; |
| ... | @@ -355,6 +357,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -355,6 +357,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 355 | readonly snoozeSupported: boolean; | 357 | readonly snoozeSupported: boolean; |
| 356 | /** False on servers that predate thread.pin/unpin. */ | 358 | /** False on servers that predate thread.pin/unpin. */ |
| 357 | readonly pinningSupported: boolean; | 359 | readonly pinningSupported: boolean; |
| 360 | /** False on servers that predate thread title regeneration. */ | ||
| 361 | readonly titleRegenerationSupported: boolean; | ||
| 358 | /** False on servers that predate thread.pin.reorder. Gates the pinned | 362 | /** False on servers that predate thread.pin.reorder. Gates the pinned |
| 359 | Move up / Move down menu items. */ | 363 | Move up / Move down menu items. */ |
| 360 | readonly pinReorderSupported?: boolean; | 364 | readonly pinReorderSupported?: boolean; |
| ... | @@ -384,6 +388,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -384,6 +388,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 384 | variant, | 388 | variant, |
| 385 | onSelectThread, | 389 | onSelectThread, |
| 386 | onDeleteThread, | 390 | onDeleteThread, |
| 391 | onRegenerateThreadTitle, | ||
| 387 | onSettleThread, | 392 | onSettleThread, |
| 388 | onSnoozeThread, | 393 | onSnoozeThread, |
| 389 | onUnsnoozeThread, | 394 | onUnsnoozeThread, |
| ... | @@ -417,6 +422,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -417,6 +422,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 417 | const timeLabel = threadTimeLabel(thread); | 422 | const timeLabel = threadTimeLabel(thread); |
| 418 | 423 | ||
| 419 | const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); | 424 | const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); |
| 425 | const handleRegenerateTitle = useCallback( | ||
| 426 | () => onRegenerateThreadTitle(thread), | ||
| 427 | [onRegenerateThreadTitle, thread], | ||
| 428 | ); | ||
| 420 | const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); | 429 | const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); |
| 421 | const handleSnooze = useCallback( | 430 | const handleSnooze = useCallback( |
| 422 | (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil), | 431 | (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil), |
| ... | @@ -506,6 +515,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -506,6 +515,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 506 | props.pinningSupported, | 515 | props.pinningSupported, |
| 507 | ], | 516 | ], |
| 508 | ); | 517 | ); |
| 518 | const titleRegenerationMenuItems = useMemo<MenuAction[]>( | ||
| 519 | () => | ||
| 520 | buildThreadTitleRegenerationMenuItems({ | ||
| 521 | supported: props.titleRegenerationSupported, | ||
| 522 | isRegenerating: thread.titleRegeneration != null, | ||
| 523 | }), | ||
| 524 | [props.titleRegenerationSupported, thread.titleRegeneration], | ||
| 525 | ); | ||
| 509 | const snoozableCardMenuActions = useMemo<MenuAction[]>( | 526 | const snoozableCardMenuActions = useMemo<MenuAction[]>( |
| 510 | () => [ | 527 | () => [ |
| 511 | { id: "settle", title: "Settle", image: "checkmark" }, | 528 | { id: "settle", title: "Settle", image: "checkmark" }, |
| ... | @@ -516,13 +533,31 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -516,13 +533,31 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 516 | subactions: snoozePresetActions, | 533 | subactions: snoozePresetActions, |
| 517 | }, | 534 | }, |
| 518 | ...pinMenuItem, | 535 | ...pinMenuItem, |
| 536 | ...titleRegenerationMenuItems, | ||
| 519 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, | 537 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, |
| 520 | ], | 538 | ], |
| 521 | [pinMenuItem, snoozePresetActions], | 539 | [pinMenuItem, snoozePresetActions, titleRegenerationMenuItems], |
| 522 | ); | 540 | ); |
| 523 | const cardMenuActions = useMemo<MenuAction[]>( | 541 | const cardMenuActions = useMemo<MenuAction[]>( |
| 524 | () => [CARD_MENU_ACTIONS[0]!, ...pinMenuItem, ...CARD_MENU_ACTIONS.slice(1)], | 542 | () => [ |
| 525 | [pinMenuItem], | 543 | CARD_MENU_ACTIONS[0]!, |
| 544 | ...pinMenuItem, | ||
| 545 | ...titleRegenerationMenuItems, | ||
| 546 | ...CARD_MENU_ACTIONS.slice(1), | ||
| 547 | ], | ||
| 548 | [pinMenuItem, titleRegenerationMenuItems], | ||
| 549 | ); | ||
| 550 | const slimMenuActions = useMemo<MenuAction[]>( | ||
| 551 | () => [SLIM_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SLIM_MENU_ACTIONS[1]!], | ||
| 552 | [titleRegenerationMenuItems], | ||
| 553 | ); | ||
| 554 | const snoozedMenuActions = useMemo<MenuAction[]>( | ||
| 555 | () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], | ||
| 556 | [titleRegenerationMenuItems], | ||
| 557 | ); | ||
| 558 | const legacyMenuActions = useMemo<MenuAction[]>( | ||
| 559 | () => [LEGACY_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, LEGACY_MENU_ACTIONS[1]!], | ||
| 560 | [titleRegenerationMenuItems], | ||
| 526 | ); | 561 | ); |
| 527 | const handleMenuAction = useCallback( | 562 | const handleMenuAction = useCallback( |
| 528 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { | 563 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { |
| ... | @@ -534,6 +569,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -534,6 +569,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 534 | if (nativeEvent.event === "move-pin-up") handleMovePinnedUp(); | 569 | if (nativeEvent.event === "move-pin-up") handleMovePinnedUp(); |
| 535 | if (nativeEvent.event === "move-pin-down") handleMovePinnedDown(); | 570 | if (nativeEvent.event === "move-pin-down") handleMovePinnedDown(); |
| 536 | if (nativeEvent.event === "archive") handleArchive(); | 571 | if (nativeEvent.event === "archive") handleArchive(); |
| 572 | if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); | ||
| 537 | if (nativeEvent.event === "delete") handleDelete(); | 573 | if (nativeEvent.event === "delete") handleDelete(); |
| 538 | const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ | 574 | const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ |
| 539 | event: nativeEvent.event, | 575 | event: nativeEvent.event, |
| ... | @@ -549,6 +585,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -549,6 +585,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 549 | [ | 585 | [ |
| 550 | handleArchive, | 586 | handleArchive, |
| 551 | handleDelete, | 587 | handleDelete, |
| 588 | handleRegenerateTitle, | ||
| 552 | handleMovePinnedDown, | 589 | handleMovePinnedDown, |
| 553 | handleMovePinnedUp, | 590 | handleMovePinnedUp, |
| 554 | handlePin, | 591 | handlePin, |
| ... | @@ -890,11 +927,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -890,11 +927,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 890 | <ControlPillMenu | 927 | <ControlPillMenu |
| 891 | actions={ | 928 | actions={ |
| 892 | snoozedRow | 929 | snoozedRow |
| 893 | ? SNOOZED_MENU_ACTIONS | 930 | ? snoozedMenuActions |
| 894 | : !props.settlementSupported | 931 | : !props.settlementSupported |
| 895 | ? LEGACY_MENU_ACTIONS | 932 | ? legacyMenuActions |
| 896 | : canUnsettle | 933 | : canUnsettle |
| 897 | ? SLIM_MENU_ACTIONS | 934 | ? slimMenuActions |
| 898 | : swipeActions.secondary === "snooze" | 935 | : swipeActions.secondary === "snooze" |
| 899 | ? snoozableCardMenuActions | 936 | ? snoozableCardMenuActions |
| 900 | : cardMenuActions | 937 | : cardMenuActions |
apps/mobile/src/features/threads/thread-title-regeneration-menu.test.ts created+36| ... | @@ -0,0 +1,36 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; | ||
| 4 | |||
| 5 | describe("buildThreadTitleRegenerationMenuItems", () => { | ||
| 6 | it("hides regeneration when the environment does not advertise support", () => { | ||
| 7 | expect( | ||
| 8 | buildThreadTitleRegenerationMenuItems({ supported: false, isRegenerating: false }), | ||
| 9 | ).toEqual([]); | ||
| 10 | }); | ||
| 11 | |||
| 12 | it("offers regeneration for a supported environment", () => { | ||
| 13 | expect( | ||
| 14 | buildThreadTitleRegenerationMenuItems({ supported: true, isRegenerating: false }), | ||
| 15 | ).toEqual([ | ||
| 16 | { | ||
| 17 | id: "regenerate-title", | ||
| 18 | title: "Regenerate title", | ||
| 19 | image: "arrow.clockwise", | ||
| 20 | }, | ||
| 21 | ]); | ||
| 22 | }); | ||
| 23 | |||
| 24 | it("shows and disables the pending state", () => { | ||
| 25 | expect( | ||
| 26 | buildThreadTitleRegenerationMenuItems({ supported: true, isRegenerating: true }), | ||
| 27 | ).toEqual([ | ||
| 28 | { | ||
| 29 | id: "regenerate-title", | ||
| 30 | title: "Regenerating…", | ||
| 31 | image: "arrow.clockwise", | ||
| 32 | attributes: { disabled: true }, | ||
| 33 | }, | ||
| 34 | ]); | ||
| 35 | }); | ||
| 36 | }); | ||
apps/mobile/src/features/threads/thread-title-regeneration-menu.ts created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | import type { MenuAction } from "@react-native-menu/menu"; | ||
| 2 | |||
| 3 | export function buildThreadTitleRegenerationMenuItems(input: { | ||
| 4 | readonly supported: boolean; | ||
| 5 | readonly isRegenerating: boolean; | ||
| 6 | }): MenuAction[] { | ||
| 7 | if (!input.supported) return []; | ||
| 8 | |||
| 9 | return [ | ||
| 10 | { | ||
| 11 | id: "regenerate-title", | ||
| 12 | title: input.isRegenerating ? "Regenerating…" : "Regenerate title", | ||
| 13 | image: "arrow.clockwise", | ||
| 14 | ...(input.isRegenerating ? { attributes: { disabled: true } } : {}), | ||
| 15 | }, | ||
| 16 | ]; | ||
| 17 | } | ||
docs/user/thread-sidebar.md+4| ... | @@ -18,3 +18,7 @@ Dev and Nightly environments can identify themselves with artwork at the top of | ... | @@ -18,3 +18,7 @@ Dev and Nightly environments can identify themselves with artwork at the top of |
| 18 | the send button. Choose **Artwork**, **Version pill**, or **None** in Settings under environment | 18 | the send button. Choose **Artwork**, **Version pill**, or **None** in Settings under environment |
| 19 | identification. Artwork is recolored to match each built-in theme. Custom themes use the **Version | 19 | identification. Artwork is recolored to match each built-in theme. Custom themes use the **Version |
| 20 | pill** fallback because their colors are not controlled by T3 Code. | 20 | pill** fallback because their colors are not controlled by T3 Code. |
| 21 | |||
| 22 | To generate a fresh title from the conversation, open a thread's context menu and choose | ||
| 23 | **Regenerate title**. While T3 Code is generating it, the action reads **Regenerating…** and cannot | ||
| 24 | be selected again. The option is hidden when the connected environment needs a server update. |