authorgravatar for chris@xenforo.comChris Deeming <chris@xenforo.com> 2026-08-12 02:43:05+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-11 21:43:05-04:00
logd37a9b09b8ea6b014e9ea2d6b00d9b25cde0efc6
treeccca892469cc49265734449f6ba21f3133add7f1
parent560d4a4560ddb5f42c8f8e0e35fa7827c0e46f80
signature Signed by PGP key B5690EEEBB952194

feat(mobile): add thread title regeneration (#6253)


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 filter550 // 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 the634 // Canonical arranged pinned order (reorder-capable threads only) for the
619 // Move up/down position flags. Computed from all shells, not the rendered635 // 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}
5050
51function environmentSupportsTitleRegeneration(
52 environmentId: EnvironmentThreadShell["environmentId"],
53) {
54 return (
55 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
56 .threadTitleRegeneration === true
57 );
58}
59
51type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";60type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";
5261
53const ACTION_VERBS: Record<ThreadListAction, string> = {62const 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>());
237251
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 );
408463
409 // Move up / Move down for the pinned block. Computed against the CANONICAL464 // 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 even465 // 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}
502558
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 from518 // 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";
22import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr";22import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr";
23import type { HomeGroupDisplayAction } from "../home/homeListItems";23import type { HomeGroupDisplayAction } from "../home/homeListItems";
24import { ThreadSwipeable } from "../home/thread-swipe-actions";24import { ThreadSwipeable } from "../home/thread-swipe-actions";
25import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
25import { resolveThreadStatus } from "./threadPresentation";26import { resolveThreadStatus } from "./threadPresentation";
26import { ThreadSearchMatchExcerpt } from "./thread-search-match";27import { ThreadSearchMatchExcerpt } from "./thread-search-match";
2728
...@@ -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");
453456
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: {
471475
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 );
490510
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 the694 // 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 <ControlPillMenu696 <ControlPillMenu
677 actions={THREAD_ROW_MENU_ACTIONS}697 actions={menuActions}
678 onPressAction={handleMenuAction}698 onPressAction={handleMenuAction}
679 shouldOpenOnLongPress699 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";
27import type { PendingNewTask } from "../../state/use-pending-new-tasks";27import type { PendingNewTask } from "../../state/use-pending-new-tasks";
28import { useThreadPr } from "../../state/use-thread-pr";28import { useThreadPr } from "../../state/use-thread-pr";
29import { ThreadSwipeable } from "../home/thread-swipe-actions";29import { ThreadSwipeable } from "../home/thread-swipe-actions";
30import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
30import {31import {
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}
6768
68// Menus stay lifecycle-focused: settle/un-settle plus delete. Archive keeps69// 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.
70const CARD_MENU_ACTIONS: MenuAction[] = [71const 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 pinned362 /** 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);
418423
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 <ControlPillMenu927 <ControlPillMenu
891 actions={928 actions={
892 snoozedRow929 snoozedRow
893 ? SNOOZED_MENU_ACTIONS930 ? snoozedMenuActions
894 : !props.settlementSupported931 : !props.settlementSupported
895 ? LEGACY_MENU_ACTIONS932 ? legacyMenuActions
896 : canUnsettle933 : canUnsettle
897 ? SLIM_MENU_ACTIONS934 ? slimMenuActions
898 : swipeActions.secondary === "snooze"935 : swipeActions.secondary === "snooze"
899 ? snoozableCardMenuActions936 ? snoozableCardMenuActions
900 : cardMenuActions937 : cardMenuActions
apps/mobile/src/features/threads/thread-title-regeneration-menu.test.ts created+36
...@@ -0,0 +1,36 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
4
5describe("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 @@
1import type { MenuAction } from "@react-native-menu/menu";
2
3export 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
18the send button. Choose **Artwork**, **Version pill**, or **None** in Settings under environment18the send button. Choose **Artwork**, **Version pill**, or **None** in Settings under environment
19identification. Artwork is recolored to match each built-in theme. Custom themes use the **Version19identification. Artwork is recolored to match each built-in theme. Custom themes use the **Version
20pill** fallback because their colors are not controlled by T3 Code.20pill** fallback because their colors are not controlled by T3 Code.
21
22To 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
24be selected again. The option is hidden when the connected environment needs a server update.