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() {
4545 pinThread,
4646 unpinThread,
4747 movePinnedThread,
48 regenerateThreadTitle,
4849 unsettleThread,
4950 } = useThreadListActions();
5051 const pendingTasks = usePendingNewTasks();
......@@ -186,6 +187,7 @@ export function HomeRouteScreen() {
186187 onPinThread={pinThread}
187188 onUnpinThread={unpinThread}
188189 onMovePinnedThread={movePinnedThread}
190 onRegenerateThreadTitle={regenerateThreadTitle}
189191 onEnvironmentChange={setSelectedEnvironmentId}
190192 onProjectChange={setSelectedProjectKey}
191193 onOpenSettings={() =>
apps/mobile/src/features/home/HomeScreen.tsx+24
......@@ -115,6 +115,7 @@ interface HomeScreenProps {
115115 thread: EnvironmentThreadShell,
116116 direction: "up" | "down",
117117 ) => Promise<boolean>;
118 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;
118119 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
119120 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
120121 readonly onNewThreadInProject: (project: EnvironmentProject) => void;
......@@ -538,6 +539,12 @@ export function HomeScreen(props: HomeScreenProps) {
538539 },
539540 [props.onUnpinThread],
540541 );
542 const handleRegenerateThreadTitle = useCallback(
543 (thread: EnvironmentThreadShell) => {
544 void props.onRegenerateThreadTitle(thread);
545 },
546 [props.onRegenerateThreadTitle],
547 );
541548 const handleDeleteThread = props.onDeleteThread;
542549 const handleUnsettleThread = props.onUnsettleThread;
543550 // The settled tail renders in pages; expansion resets when the filter
......@@ -615,6 +622,15 @@ export function HomeScreen(props: HomeScreenProps) {
615622 }
616623 return supported;
617624 }, [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]);
618634 // Canonical arranged pinned order (reorder-capable threads only) for the
619635 // Move up/down position flags. Computed from all shells, not the rendered
620636 // list, so search/scope filtering never disables or misdirects a move.
......@@ -811,6 +827,8 @@ export function HomeScreen(props: HomeScreenProps) {
811827 onSelectThread={props.onSelectThread}
812828 onDeleteThread={handleDeleteThread}
813829 onArchiveThread={props.onArchiveThread}
830 onRegenerateThreadTitle={handleRegenerateThreadTitle}
831 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
814832 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
815833 onSettleThread={handleSettleThread}
816834 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
......@@ -842,6 +860,7 @@ export function HomeScreen(props: HomeScreenProps) {
842860 arrangedPinnedKeys,
843861 handleMovePinnedThread,
844862 handlePinThread,
863 handleRegenerateThreadTitle,
845864 handleSettleThread,
846865 handleSnoozeThread,
847866 handleUnpinThread,
......@@ -863,6 +882,7 @@ export function HomeScreen(props: HomeScreenProps) {
863882 snoozeEnvironmentIds,
864883 threadListV2Items,
865884 threadSearchMatchByKey,
885 titleRegenerationEnvironmentIds,
866886 toggleSettledShelf,
867887 toggleSnoozedShelf,
868888 v2ProjectTitleByProjectKey,
......@@ -967,6 +987,8 @@ export function HomeScreen(props: HomeScreenProps) {
967987 searchQuery={props.searchQuery}
968988 onArchiveThread={props.onArchiveThread}
969989 onDeleteThread={props.onDeleteThread}
990 onRegenerateThreadTitle={handleRegenerateThreadTitle}
991 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
970992 onSelectThread={props.onSelectThread}
971993 onSwipeableClose={handleSwipeableClose}
972994 onSwipeableWillOpen={handleSwipeableWillOpen}
......@@ -988,6 +1010,7 @@ export function HomeScreen(props: HomeScreenProps) {
9881010 [
9891011 handleSwipeableClose,
9901012 handleSwipeableWillOpen,
1013 handleRegenerateThreadTitle,
9911014 projectCwdByKey,
9921015 props.onArchiveThread,
9931016 props.onDeletePendingTask,
......@@ -998,6 +1021,7 @@ export function HomeScreen(props: HomeScreenProps) {
9981021 props.searchQuery,
9991022 props.savedConnectionsById,
10001023 threadSearchMatchByKey,
1024 titleRegenerationEnvironmentIds,
10011025 updateGroupDisplay,
10021026 ],
10031027 );
apps/mobile/src/features/home/useThreadListActions.ts+56
......@@ -48,6 +48,15 @@ function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["en
4848 );
4949}
5050
51function environmentSupportsTitleRegeneration(
52 environmentId: EnvironmentThreadShell["environmentId"],
53) {
54 return (
55 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
56 .threadTitleRegeneration === true
57 );
58}
59
5160type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";
5261
5362const ACTION_VERBS: Record<ThreadListAction, string> = {
......@@ -227,13 +236,18 @@ export function useThreadListActions(): {
227236 thread: EnvironmentThreadShell,
228237 direction: "up" | "down",
229238 ) => Promise<boolean>;
239 readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;
230240} {
231241 const executeAction = useThreadActionExecutor();
232242 const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false });
233243 const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false });
234244 const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false });
235245 const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false });
246 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
247 reportFailure: false,
248 });
236249 const snoozeInFlightThreadKeys = useRef(new Set<string>());
250 const titleRegenerationInFlightThreadKeys = useRef(new Set<string>());
237251
238252 const archiveThread = useCallback(
239253 (thread: EnvironmentThreadShell) => {
......@@ -405,6 +419,47 @@ export function useThreadListActions(): {
405419 },
406420 [unpinMutation],
407421 );
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
409464 // Move up / Move down for the pinned block. Computed against the CANONICAL
410465 // keyed pinned order (not the rendered list), so the move is valid even
......@@ -497,6 +552,7 @@ export function useThreadListActions(): {
497552 pinThread,
498553 unpinThread,
499554 movePinnedThread,
555 regenerateThreadTitle,
500556 };
501557}
502558
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+16
......@@ -211,6 +211,7 @@ function ThreadNavigationSidebarPane(
211211 pinThread,
212212 unpinThread,
213213 movePinnedThread,
214 regenerateThreadTitle,
214215 } = useThreadListActions();
215216 const threadListV2Enabled = useThreadListV2Enabled();
216217 const pendingTasks = usePendingNewTasks();
......@@ -505,6 +506,15 @@ function ThreadNavigationSidebarPane(
505506 }
506507 return supported;
507508 }, [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]);
508518 // Canonical arranged pinned order for Move up/down flags — computed from
509519 // all shells so search/scope filtering never disables a valid move.
510520 const arrangedPinnedKeys = useMemo(() => {
......@@ -950,6 +960,8 @@ function ThreadNavigationSidebarPane(
950960 onSelectThread={handleSelectThread}
951961 onDeleteThread={confirmDeleteThread}
952962 onArchiveThread={archiveThread}
963 onRegenerateThreadTitle={regenerateThreadTitle}
964 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
953965 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
954966 onSettleThread={settleThread}
955967 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
......@@ -1067,6 +1079,8 @@ function ThreadNavigationSidebarPane(
10671079 fullSwipeWidth={props.width - 20}
10681080 onArchiveThread={archiveThread}
10691081 onDeleteThread={confirmDeleteThread}
1082 onRegenerateThreadTitle={regenerateThreadTitle}
1083 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
10701084 onSelectThread={handleSelectThread}
10711085 onSwipeableClose={handleSwipeableClose}
10721086 onSwipeableWillOpen={handleSwipeableWillOpen}
......@@ -1103,6 +1117,7 @@ function ThreadNavigationSidebarPane(
11031117 projectByKey,
11041118 projectCwdByKey,
11051119 projectTitleByProjectKey,
1120 regenerateThreadTitle,
11061121 props.onNewThreadInProject,
11071122 props.searchQuery,
11081123 props.selectedThreadKey,
......@@ -1110,6 +1125,7 @@ function ThreadNavigationSidebarPane(
11101125 savedConnectionsById,
11111126 serverConfigs,
11121127 threadSearchMatchByKey,
1128 titleRegenerationEnvironmentIds,
11131129 settleThread,
11141130 settlementEnvironmentIds,
11151131 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";
2222import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr";
2323import type { HomeGroupDisplayAction } from "../home/homeListItems";
2424import { ThreadSwipeable } from "../home/thread-swipe-actions";
25import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
2526import { resolveThreadStatus } from "./threadPresentation";
2627import { ThreadSearchMatchExcerpt } from "./thread-search-match";
2728
......@@ -430,6 +431,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
430431 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
431432 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
432433 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
434 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;
435 readonly titleRegenerationSupported: boolean;
433436 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
434437 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
435438 readonly simultaneousSwipeGesture?: ComponentProps<
......@@ -451,7 +454,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
451454 const pressedBackgroundColor = useThemeColor("--color-subtle");
452455 const selectedBackgroundColor = useThemeColor("--color-user-bubble");
453456
454 const { thread, onSelectThread, onArchiveThread, onDeleteThread } = props;
457 const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } =
458 props;
455459 const status = resolveThreadStatus(thread);
456460 const pr = useThreadPr(thread, props.projectCwd);
457461 const timestamp = relativeTime(
......@@ -471,6 +475,21 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
471475
472476 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
473477 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 );
474493 const primaryAction = useMemo(
475494 () => ({
476495 accessibilityLabel: `Archive ${thread.title}`,
......@@ -483,9 +502,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
483502 const handleMenuAction = useCallback(
484503 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
485504 if (nativeEvent.event === "archive") handleArchive();
505 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
486506 if (nativeEvent.event === "delete") handleDelete();
487507 },
488 [handleArchive, handleDelete],
508 [handleArchive, handleDelete, handleRegenerateTitle],
489509 );
490510
491511 const statusPill = effectiveStatus ? (
......@@ -674,7 +694,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
674694 // ControlPillMenu injects onLongPress into the row and anchors the
675695 // token-styled dropdown to it; taps and swipes are untouched.
676696 <ControlPillMenu
677 actions={THREAD_ROW_MENU_ACTIONS}
697 actions={menuActions}
678698 onPressAction={handleMenuAction}
679699 shouldOpenOnLongPress
680700 >
apps/mobile/src/features/threads/thread-list-v2-items.tsx+45-8
......@@ -27,6 +27,7 @@ import { useThemeColor } from "../../lib/useThemeColor";
2727import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2828import { useThreadPr } from "../../state/use-thread-pr";
2929import { ThreadSwipeable } from "../home/thread-swipe-actions";
30import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
3031import {
3132 resolveThreadListV2SnoozeMenuSelection,
3233 resolveThreadListV2SnoozeGateExpiryMs,
......@@ -65,8 +66,8 @@ function threadTimeLabel(thread: EnvironmentThreadShell): string {
6566 return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt);
6667}
6768
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.
7071const CARD_MENU_ACTIONS: MenuAction[] = [
7172 { id: "settle", title: "Settle", image: "checkmark" },
7273 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
......@@ -341,6 +342,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
341342 readonly fullSwipeWidth?: number;
342343 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
343344 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
345 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;
344346 readonly onSettleThread: (thread: EnvironmentThreadShell) => void;
345347 readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void;
346348 readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void;
......@@ -355,6 +357,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
355357 readonly snoozeSupported: boolean;
356358 /** False on servers that predate thread.pin/unpin. */
357359 readonly pinningSupported: boolean;
360 /** False on servers that predate thread title regeneration. */
361 readonly titleRegenerationSupported: boolean;
358362 /** False on servers that predate thread.pin.reorder. Gates the pinned
359363 Move up / Move down menu items. */
360364 readonly pinReorderSupported?: boolean;
......@@ -384,6 +388,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
384388 variant,
385389 onSelectThread,
386390 onDeleteThread,
391 onRegenerateThreadTitle,
387392 onSettleThread,
388393 onSnoozeThread,
389394 onUnsnoozeThread,
......@@ -417,6 +422,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
417422 const timeLabel = threadTimeLabel(thread);
418423
419424 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
425 const handleRegenerateTitle = useCallback(
426 () => onRegenerateThreadTitle(thread),
427 [onRegenerateThreadTitle, thread],
428 );
420429 const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]);
421430 const handleSnooze = useCallback(
422431 (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil),
......@@ -506,6 +515,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
506515 props.pinningSupported,
507516 ],
508517 );
518 const titleRegenerationMenuItems = useMemo<MenuAction[]>(
519 () =>
520 buildThreadTitleRegenerationMenuItems({
521 supported: props.titleRegenerationSupported,
522 isRegenerating: thread.titleRegeneration != null,
523 }),
524 [props.titleRegenerationSupported, thread.titleRegeneration],
525 );
509526 const snoozableCardMenuActions = useMemo<MenuAction[]>(
510527 () => [
511528 { id: "settle", title: "Settle", image: "checkmark" },
......@@ -516,13 +533,31 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
516533 subactions: snoozePresetActions,
517534 },
518535 ...pinMenuItem,
536 ...titleRegenerationMenuItems,
519537 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
520538 ],
521 [pinMenuItem, snoozePresetActions],
539 [pinMenuItem, snoozePresetActions, titleRegenerationMenuItems],
522540 );
523541 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],
526561 );
527562 const handleMenuAction = useCallback(
528563 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
......@@ -534,6 +569,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
534569 if (nativeEvent.event === "move-pin-up") handleMovePinnedUp();
535570 if (nativeEvent.event === "move-pin-down") handleMovePinnedDown();
536571 if (nativeEvent.event === "archive") handleArchive();
572 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
537573 if (nativeEvent.event === "delete") handleDelete();
538574 const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({
539575 event: nativeEvent.event,
......@@ -549,6 +585,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
549585 [
550586 handleArchive,
551587 handleDelete,
588 handleRegenerateTitle,
552589 handleMovePinnedDown,
553590 handleMovePinnedUp,
554591 handlePin,
......@@ -890,11 +927,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
890927 <ControlPillMenu
891928 actions={
892929 snoozedRow
893 ? SNOOZED_MENU_ACTIONS
930 ? snoozedMenuActions
894931 : !props.settlementSupported
895 ? LEGACY_MENU_ACTIONS
932 ? legacyMenuActions
896933 : canUnsettle
897 ? SLIM_MENU_ACTIONS
934 ? slimMenuActions
898935 : swipeActions.secondary === "snooze"
899936 ? snoozableCardMenuActions
900937 : 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
1818the send button. Choose **Artwork**, **Version pill**, or **None** in Settings under environment
1919identification. Artwork is recolored to match each built-in theme. Custom themes use the **Version
2020pill** 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.