| 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 | 45 | pinThread, |
| 46 | 46 | unpinThread, |
| 47 | 47 | movePinnedThread, |
| 48 | regenerateThreadTitle, | |
| 48 | 49 | unsettleThread, |
| 49 | 50 | } = useThreadListActions(); |
| 50 | 51 | const pendingTasks = usePendingNewTasks(); |
| ... | ... | @@ -186,6 +187,7 @@ export function HomeRouteScreen() { |
| 186 | 187 | onPinThread={pinThread} |
| 187 | 188 | onUnpinThread={unpinThread} |
| 188 | 189 | onMovePinnedThread={movePinnedThread} |
| 190 | onRegenerateThreadTitle={regenerateThreadTitle} | |
| 189 | 191 | onEnvironmentChange={setSelectedEnvironmentId} |
| 190 | 192 | onProjectChange={setSelectedProjectKey} |
| 191 | 193 | onOpenSettings={() => |
apps/mobile/src/features/home/HomeScreen.tsx+24| ... | ... | @@ -115,6 +115,7 @@ interface HomeScreenProps { |
| 115 | 115 | thread: EnvironmentThreadShell, |
| 116 | 116 | direction: "up" | "down", |
| 117 | 117 | ) => Promise<boolean>; |
| 118 | readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>; | |
| 118 | 119 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; |
| 119 | 120 | readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; |
| 120 | 121 | readonly onNewThreadInProject: (project: EnvironmentProject) => void; |
| ... | ... | @@ -538,6 +539,12 @@ export function HomeScreen(props: HomeScreenProps) { |
| 538 | 539 | }, |
| 539 | 540 | [props.onUnpinThread], |
| 540 | 541 | ); |
| 542 | const handleRegenerateThreadTitle = useCallback( | |
| 543 | (thread: EnvironmentThreadShell) => { | |
| 544 | void props.onRegenerateThreadTitle(thread); | |
| 545 | }, | |
| 546 | [props.onRegenerateThreadTitle], | |
| 547 | ); | |
| 541 | 548 | const handleDeleteThread = props.onDeleteThread; |
| 542 | 549 | const handleUnsettleThread = props.onUnsettleThread; |
| 543 | 550 | // The settled tail renders in pages; expansion resets when the filter |
| ... | ... | @@ -615,6 +622,15 @@ export function HomeScreen(props: HomeScreenProps) { |
| 615 | 622 | } |
| 616 | 623 | return supported; |
| 617 | 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 | 634 | // Canonical arranged pinned order (reorder-capable threads only) for the |
| 619 | 635 | // Move up/down position flags. Computed from all shells, not the rendered |
| 620 | 636 | // list, so search/scope filtering never disables or misdirects a move. |
| ... | ... | @@ -811,6 +827,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 811 | 827 | onSelectThread={props.onSelectThread} |
| 812 | 828 | onDeleteThread={handleDeleteThread} |
| 813 | 829 | onArchiveThread={props.onArchiveThread} |
| 830 | onRegenerateThreadTitle={handleRegenerateThreadTitle} | |
| 831 | titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} | |
| 814 | 832 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} |
| 815 | 833 | onSettleThread={handleSettleThread} |
| 816 | 834 | snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} |
| ... | ... | @@ -842,6 +860,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 842 | 860 | arrangedPinnedKeys, |
| 843 | 861 | handleMovePinnedThread, |
| 844 | 862 | handlePinThread, |
| 863 | handleRegenerateThreadTitle, | |
| 845 | 864 | handleSettleThread, |
| 846 | 865 | handleSnoozeThread, |
| 847 | 866 | handleUnpinThread, |
| ... | ... | @@ -863,6 +882,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 863 | 882 | snoozeEnvironmentIds, |
| 864 | 883 | threadListV2Items, |
| 865 | 884 | threadSearchMatchByKey, |
| 885 | titleRegenerationEnvironmentIds, | |
| 866 | 886 | toggleSettledShelf, |
| 867 | 887 | toggleSnoozedShelf, |
| 868 | 888 | v2ProjectTitleByProjectKey, |
| ... | ... | @@ -967,6 +987,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 967 | 987 | searchQuery={props.searchQuery} |
| 968 | 988 | onArchiveThread={props.onArchiveThread} |
| 969 | 989 | onDeleteThread={props.onDeleteThread} |
| 990 | onRegenerateThreadTitle={handleRegenerateThreadTitle} | |
| 991 | titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} | |
| 970 | 992 | onSelectThread={props.onSelectThread} |
| 971 | 993 | onSwipeableClose={handleSwipeableClose} |
| 972 | 994 | onSwipeableWillOpen={handleSwipeableWillOpen} |
| ... | ... | @@ -988,6 +1010,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 988 | 1010 | [ |
| 989 | 1011 | handleSwipeableClose, |
| 990 | 1012 | handleSwipeableWillOpen, |
| 1013 | handleRegenerateThreadTitle, | |
| 991 | 1014 | projectCwdByKey, |
| 992 | 1015 | props.onArchiveThread, |
| 993 | 1016 | props.onDeletePendingTask, |
| ... | ... | @@ -998,6 +1021,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 998 | 1021 | props.searchQuery, |
| 999 | 1022 | props.savedConnectionsById, |
| 1000 | 1023 | threadSearchMatchByKey, |
| 1024 | titleRegenerationEnvironmentIds, | |
| 1001 | 1025 | updateGroupDisplay, |
| 1002 | 1026 | ], |
| 1003 | 1027 | ); |
apps/mobile/src/features/home/useThreadListActions.ts+56| ... | ... | @@ -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 | 60 | type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; |
| 52 | 61 | |
| 53 | 62 | const ACTION_VERBS: Record<ThreadListAction, string> = { |
| ... | ... | @@ -227,13 +236,18 @@ export function useThreadListActions(): { |
| 227 | 236 | thread: EnvironmentThreadShell, |
| 228 | 237 | direction: "up" | "down", |
| 229 | 238 | ) => Promise<boolean>; |
| 239 | readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>; | |
| 230 | 240 | } { |
| 231 | 241 | const executeAction = useThreadActionExecutor(); |
| 232 | 242 | const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); |
| 233 | 243 | const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); |
| 234 | 244 | const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false }); |
| 235 | 245 | const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false }); |
| 246 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | |
| 247 | reportFailure: false, | |
| 248 | }); | |
| 236 | 249 | const snoozeInFlightThreadKeys = useRef(new Set<string>()); |
| 250 | const titleRegenerationInFlightThreadKeys = useRef(new Set<string>()); | |
| 237 | 251 | |
| 238 | 252 | const archiveThread = useCallback( |
| 239 | 253 | (thread: EnvironmentThreadShell) => { |
| ... | ... | @@ -405,6 +419,47 @@ export function useThreadListActions(): { |
| 405 | 419 | }, |
| 406 | 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 | 464 | // Move up / Move down for the pinned block. Computed against the CANONICAL |
| 410 | 465 | // keyed pinned order (not the rendered list), so the move is valid even |
| ... | ... | @@ -497,6 +552,7 @@ export function useThreadListActions(): { |
| 497 | 552 | pinThread, |
| 498 | 553 | unpinThread, |
| 499 | 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 | 211 | pinThread, |
| 212 | 212 | unpinThread, |
| 213 | 213 | movePinnedThread, |
| 214 | regenerateThreadTitle, | |
| 214 | 215 | } = useThreadListActions(); |
| 215 | 216 | const threadListV2Enabled = useThreadListV2Enabled(); |
| 216 | 217 | const pendingTasks = usePendingNewTasks(); |
| ... | ... | @@ -505,6 +506,15 @@ function ThreadNavigationSidebarPane( |
| 505 | 506 | } |
| 506 | 507 | return supported; |
| 507 | 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 | 518 | // Canonical arranged pinned order for Move up/down flags — computed from |
| 509 | 519 | // all shells so search/scope filtering never disables a valid move. |
| 510 | 520 | const arrangedPinnedKeys = useMemo(() => { |
| ... | ... | @@ -950,6 +960,8 @@ function ThreadNavigationSidebarPane( |
| 950 | 960 | onSelectThread={handleSelectThread} |
| 951 | 961 | onDeleteThread={confirmDeleteThread} |
| 952 | 962 | onArchiveThread={archiveThread} |
| 963 | onRegenerateThreadTitle={regenerateThreadTitle} | |
| 964 | titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} | |
| 953 | 965 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} |
| 954 | 966 | onSettleThread={settleThread} |
| 955 | 967 | snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} |
| ... | ... | @@ -1067,6 +1079,8 @@ function ThreadNavigationSidebarPane( |
| 1067 | 1079 | fullSwipeWidth={props.width - 20} |
| 1068 | 1080 | onArchiveThread={archiveThread} |
| 1069 | 1081 | onDeleteThread={confirmDeleteThread} |
| 1082 | onRegenerateThreadTitle={regenerateThreadTitle} | |
| 1083 | titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} | |
| 1070 | 1084 | onSelectThread={handleSelectThread} |
| 1071 | 1085 | onSwipeableClose={handleSwipeableClose} |
| 1072 | 1086 | onSwipeableWillOpen={handleSwipeableWillOpen} |
| ... | ... | @@ -1103,6 +1117,7 @@ function ThreadNavigationSidebarPane( |
| 1103 | 1117 | projectByKey, |
| 1104 | 1118 | projectCwdByKey, |
| 1105 | 1119 | projectTitleByProjectKey, |
| 1120 | regenerateThreadTitle, | |
| 1106 | 1121 | props.onNewThreadInProject, |
| 1107 | 1122 | props.searchQuery, |
| 1108 | 1123 | props.selectedThreadKey, |
| ... | ... | @@ -1110,6 +1125,7 @@ function ThreadNavigationSidebarPane( |
| 1110 | 1125 | savedConnectionsById, |
| 1111 | 1126 | serverConfigs, |
| 1112 | 1127 | threadSearchMatchByKey, |
| 1128 | titleRegenerationEnvironmentIds, | |
| 1113 | 1129 | settleThread, |
| 1114 | 1130 | settlementEnvironmentIds, |
| 1115 | 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 | 22 | import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; |
| 23 | 23 | import type { HomeGroupDisplayAction } from "../home/homeListItems"; |
| 24 | 24 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; |
| 25 | import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; | |
| 25 | 26 | import { resolveThreadStatus } from "./threadPresentation"; |
| 26 | 27 | import { ThreadSearchMatchExcerpt } from "./thread-search-match"; |
| 27 | 28 | |
| ... | ... | @@ -430,6 +431,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 430 | 431 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; |
| 431 | 432 | readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; |
| 432 | 433 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; |
| 434 | readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; | |
| 435 | readonly titleRegenerationSupported: boolean; | |
| 433 | 436 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; |
| 434 | 437 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; |
| 435 | 438 | readonly simultaneousSwipeGesture?: ComponentProps< |
| ... | ... | @@ -451,7 +454,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 451 | 454 | const pressedBackgroundColor = useThemeColor("--color-subtle"); |
| 452 | 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 | 459 | const status = resolveThreadStatus(thread); |
| 456 | 460 | const pr = useThreadPr(thread, props.projectCwd); |
| 457 | 461 | const timestamp = relativeTime( |
| ... | ... | @@ -471,6 +475,21 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 471 | 475 | |
| 472 | 476 | const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); |
| 473 | 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 | 493 | const primaryAction = useMemo( |
| 475 | 494 | () => ({ |
| 476 | 495 | accessibilityLabel: `Archive ${thread.title}`, |
| ... | ... | @@ -483,9 +502,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 483 | 502 | const handleMenuAction = useCallback( |
| 484 | 503 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { |
| 485 | 504 | if (nativeEvent.event === "archive") handleArchive(); |
| 505 | if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); | |
| 486 | 506 | if (nativeEvent.event === "delete") handleDelete(); |
| 487 | 507 | }, |
| 488 | [handleArchive, handleDelete], | |
| 508 | [handleArchive, handleDelete, handleRegenerateTitle], | |
| 489 | 509 | ); |
| 490 | 510 | |
| 491 | 511 | const statusPill = effectiveStatus ? ( |
| ... | ... | @@ -674,7 +694,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 674 | 694 | // ControlPillMenu injects onLongPress into the row and anchors the |
| 675 | 695 | // token-styled dropdown to it; taps and swipes are untouched. |
| 676 | 696 | <ControlPillMenu |
| 677 | actions={THREAD_ROW_MENU_ACTIONS} | |
| 697 | actions={menuActions} | |
| 678 | 698 | onPressAction={handleMenuAction} |
| 679 | 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 | 27 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 28 | 28 | import { useThreadPr } from "../../state/use-thread-pr"; |
| 29 | 29 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; |
| 30 | import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; | |
| 30 | 31 | import { |
| 31 | 32 | resolveThreadListV2SnoozeMenuSelection, |
| 32 | 33 | resolveThreadListV2SnoozeGateExpiryMs, |
| ... | ... | @@ -65,8 +66,8 @@ function threadTimeLabel(thread: EnvironmentThreadShell): string { |
| 65 | 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 | // its own surface (thread screen / settings) rather than crowding the row. | |
| 69 | // Menus keep lifecycle and title regeneration together. Archive keeps its | |
| 70 | // own surface (thread screen / settings) rather than crowding v2 rows. | |
| 70 | 71 | const CARD_MENU_ACTIONS: MenuAction[] = [ |
| 71 | 72 | { id: "settle", title: "Settle", image: "checkmark" }, |
| 72 | 73 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, |
| ... | ... | @@ -341,6 +342,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 341 | 342 | readonly fullSwipeWidth?: number; |
| 342 | 343 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; |
| 343 | 344 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; |
| 345 | readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; | |
| 344 | 346 | readonly onSettleThread: (thread: EnvironmentThreadShell) => void; |
| 345 | 347 | readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void; |
| 346 | 348 | readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void; |
| ... | ... | @@ -355,6 +357,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 355 | 357 | readonly snoozeSupported: boolean; |
| 356 | 358 | /** False on servers that predate thread.pin/unpin. */ |
| 357 | 359 | readonly pinningSupported: boolean; |
| 360 | /** False on servers that predate thread title regeneration. */ | |
| 361 | readonly titleRegenerationSupported: boolean; | |
| 358 | 362 | /** False on servers that predate thread.pin.reorder. Gates the pinned |
| 359 | 363 | Move up / Move down menu items. */ |
| 360 | 364 | readonly pinReorderSupported?: boolean; |
| ... | ... | @@ -384,6 +388,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 384 | 388 | variant, |
| 385 | 389 | onSelectThread, |
| 386 | 390 | onDeleteThread, |
| 391 | onRegenerateThreadTitle, | |
| 387 | 392 | onSettleThread, |
| 388 | 393 | onSnoozeThread, |
| 389 | 394 | onUnsnoozeThread, |
| ... | ... | @@ -417,6 +422,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 417 | 422 | const timeLabel = threadTimeLabel(thread); |
| 418 | 423 | |
| 419 | 424 | const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); |
| 425 | const handleRegenerateTitle = useCallback( | |
| 426 | () => onRegenerateThreadTitle(thread), | |
| 427 | [onRegenerateThreadTitle, thread], | |
| 428 | ); | |
| 420 | 429 | const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); |
| 421 | 430 | const handleSnooze = useCallback( |
| 422 | 431 | (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil), |
| ... | ... | @@ -506,6 +515,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 506 | 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 | 526 | const snoozableCardMenuActions = useMemo<MenuAction[]>( |
| 510 | 527 | () => [ |
| 511 | 528 | { id: "settle", title: "Settle", image: "checkmark" }, |
| ... | ... | @@ -516,13 +533,31 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 516 | 533 | subactions: snoozePresetActions, |
| 517 | 534 | }, |
| 518 | 535 | ...pinMenuItem, |
| 536 | ...titleRegenerationMenuItems, | |
| 519 | 537 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, |
| 520 | 538 | ], |
| 521 | [pinMenuItem, snoozePresetActions], | |
| 539 | [pinMenuItem, snoozePresetActions, titleRegenerationMenuItems], | |
| 522 | 540 | ); |
| 523 | 541 | const cardMenuActions = useMemo<MenuAction[]>( |
| 524 | () => [CARD_MENU_ACTIONS[0]!, ...pinMenuItem, ...CARD_MENU_ACTIONS.slice(1)], | |
| 525 | [pinMenuItem], | |
| 542 | () => [ | |
| 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 | 562 | const handleMenuAction = useCallback( |
| 528 | 563 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { |
| ... | ... | @@ -534,6 +569,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 534 | 569 | if (nativeEvent.event === "move-pin-up") handleMovePinnedUp(); |
| 535 | 570 | if (nativeEvent.event === "move-pin-down") handleMovePinnedDown(); |
| 536 | 571 | if (nativeEvent.event === "archive") handleArchive(); |
| 572 | if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); | |
| 537 | 573 | if (nativeEvent.event === "delete") handleDelete(); |
| 538 | 574 | const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ |
| 539 | 575 | event: nativeEvent.event, |
| ... | ... | @@ -549,6 +585,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 549 | 585 | [ |
| 550 | 586 | handleArchive, |
| 551 | 587 | handleDelete, |
| 588 | handleRegenerateTitle, | |
| 552 | 589 | handleMovePinnedDown, |
| 553 | 590 | handleMovePinnedUp, |
| 554 | 591 | handlePin, |
| ... | ... | @@ -890,11 +927,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 890 | 927 | <ControlPillMenu |
| 891 | 928 | actions={ |
| 892 | 929 | snoozedRow |
| 893 | ? SNOOZED_MENU_ACTIONS | |
| 930 | ? snoozedMenuActions | |
| 894 | 931 | : !props.settlementSupported |
| 895 | ? LEGACY_MENU_ACTIONS | |
| 932 | ? legacyMenuActions | |
| 896 | 933 | : canUnsettle |
| 897 | ? SLIM_MENU_ACTIONS | |
| 934 | ? slimMenuActions | |
| 898 | 935 | : swipeActions.secondary === "snooze" |
| 899 | 936 | ? snoozableCardMenuActions |
| 900 | 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 | 18 | the send button. Choose **Artwork**, **Version pill**, or **None** in Settings under environment |
| 19 | 19 | identification. Artwork is recolored to match each built-in theme. Custom themes use the **Version |
| 20 | 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. |