diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 7760920f7dbd..398d18eb263e 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -46,6 +46,7 @@ export function HomeRouteScreen() { unpinThread, movePinnedThread, unsettleThread, + regenerateThreadTitle, } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); @@ -165,6 +166,7 @@ export function HomeRouteScreen() { } onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} + onRegenerateTitle={regenerateThreadTitle} onSettleThread={settleThread} onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 64f0480d2231..23e2ca325724 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -101,6 +101,7 @@ interface HomeScreenProps { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onRegenerateTitle: (thread: EnvironmentThreadShell) => void; /** Resolves true iff the settle was dispatched and succeeded. */ readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; readonly onSnoozeThread: ( @@ -615,6 +616,15 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const titleRegenerationEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadTitleRegeneration === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); // Canonical arranged pinned order (reorder-capable threads only) for the // Move up/down position flags. Computed from all shells, not the rendered // list, so search/scope filtering never disables or misdirects a move. @@ -811,6 +821,8 @@ export function HomeScreen(props: HomeScreenProps) { onSelectThread={props.onSelectThread} onDeleteThread={handleDeleteThread} onArchiveThread={props.onArchiveThread} + onRegenerateTitle={props.onRegenerateTitle} + titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} onSettleThread={handleSettleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} @@ -854,6 +866,7 @@ export function HomeScreen(props: HomeScreenProps) { projectByKey, projectCwdByKey, props.onArchiveThread, + props.onRegenerateTitle, props.onDeletePendingTask, props.onSelectPendingTask, props.onSelectThread, @@ -861,6 +874,7 @@ export function HomeScreen(props: HomeScreenProps) { serverConfigs, settlementEnvironmentIds, snoozeEnvironmentIds, + titleRegenerationEnvironmentIds, threadListV2Items, threadSearchMatchByKey, toggleSettledShelf, @@ -967,6 +981,8 @@ export function HomeScreen(props: HomeScreenProps) { searchQuery={props.searchQuery} onArchiveThread={props.onArchiveThread} onDeleteThread={props.onDeleteThread} + onRegenerateTitle={props.onRegenerateTitle} + titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} onSelectThread={props.onSelectThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -992,11 +1008,13 @@ export function HomeScreen(props: HomeScreenProps) { props.onArchiveThread, props.onDeletePendingTask, props.onDeleteThread, + props.onRegenerateTitle, props.onNewThreadInProject, props.onSelectPendingTask, props.onSelectThread, props.searchQuery, props.savedConnectionsById, + titleRegenerationEnvironmentIds, threadSearchMatchByKey, updateGroupDisplay, ], diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 3103c5379be2..023509b2a7f2 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -48,6 +48,15 @@ function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["en ); } +export function environmentSupportsTitleRegeneration( + environmentId: EnvironmentThreadShell["environmentId"], +) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadTitleRegeneration === true + ); +} + type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; const ACTION_VERBS: Record = { @@ -227,13 +236,18 @@ export function useThreadListActions(): { thread: EnvironmentThreadShell, direction: "up" | "down", ) => Promise; + readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise; } { const executeAction = useThreadActionExecutor(); + const updateMetadataMutation = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false }); const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false }); const snoozeInFlightThreadKeys = useRef(new Set()); + const regenerateTitleInFlightThreadKeys = useRef(new Set()); const archiveThread = useCallback( (thread: EnvironmentThreadShell) => { @@ -485,6 +499,50 @@ export function useThreadListActions(): { [reorderPinnedMutation], ); + const regenerateThreadTitle = useCallback( + async (thread: EnvironmentThreadShell) => { + if (!environmentSupportsTitleRegeneration(thread.environmentId)) { + Alert.alert( + "Could not regenerate title", + "This environment's server does not support title regeneration yet. Update the server to regenerate titles.", + ); + return false; + } + // Already regenerating: the pending job owns the title until it lands. + if (thread.titleRegeneration != null) return false; + // Double-dispatch guard: the shell's titleRegeneration flag only flips + // once the server event lands, so a second tap before then would slip + // past the check above and start duplicate generation work. Same + // per-thread in-flight set the snooze actions use. + const key = scopedThreadKey(thread.environmentId, thread.id); + if (regenerateTitleInFlightThreadKeys.current.has(key)) { + return false; + } + regenerateTitleInFlightThreadKeys.current.add(key); + selectionHaptic(); + try { + const result = await updateMetadataMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, regenerateTitle: true }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not regenerate title", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The title could not be regenerated.", + ); + return false; + } + return true; + } finally { + regenerateTitleInFlightThreadKeys.current.delete(key); + } + }, + [updateMetadataMutation], + ); + const confirmDeleteThread = useConfirmDeleteThread(executeAction); return { @@ -497,6 +555,7 @@ export function useThreadListActions(): { pinThread, unpinThread, movePinnedThread, + regenerateThreadTitle, }; } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index d80d906ada17..7f68d5fced3e 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -211,6 +211,7 @@ function ThreadNavigationSidebarPane( pinThread, unpinThread, movePinnedThread, + regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); @@ -505,6 +506,15 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const titleRegenerationEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadTitleRegeneration === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); // Canonical arranged pinned order for Move up/down flags — computed from // all shells so search/scope filtering never disables a valid move. const arrangedPinnedKeys = useMemo(() => { @@ -950,6 +960,8 @@ function ThreadNavigationSidebarPane( onSelectThread={handleSelectThread} onDeleteThread={confirmDeleteThread} onArchiveThread={archiveThread} + onRegenerateTitle={regenerateThreadTitle} + titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} onSettleThread={settleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} @@ -1067,6 +1079,8 @@ function ThreadNavigationSidebarPane( fullSwipeWidth={props.width - 20} onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} + onRegenerateTitle={regenerateThreadTitle} + titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} onSelectThread={handleSelectThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -1097,6 +1111,8 @@ function ThreadNavigationSidebarPane( handleSwipeableWillOpen, movePinnedThread, openPendingTask, + regenerateThreadTitle, + titleRegenerationEnvironmentIds, pinReorderEnvironmentIds, pinThread, pinningEnvironmentIds, diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 855713946ff8..487281f45b4c 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -409,10 +409,17 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { /* ─── Thread row ─────────────────────────────────────────────────────── */ -const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [ - { id: "archive", title: "Archive", image: "archivebox" }, - { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, -]; +const THREAD_ROW_MENU_ARCHIVE: MenuAction = { + id: "archive", + title: "Archive", + image: "archivebox", +}; +const THREAD_ROW_MENU_DELETE: MenuAction = { + id: "delete", + title: "Delete", + image: "trash", + attributes: { destructive: true }, +}; export const ThreadListRow = memo(function ThreadListRow(props: { readonly variant: ThreadListVariant; @@ -429,6 +436,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + /** Adds a "Regenerate title" menu item when provided AND the server + supports it (mirrors the web action menu's capability gate). */ + readonly onRegenerateTitle?: (thread: EnvironmentThreadShell) => void; + readonly titleRegenerationSupported?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly simultaneousSwipeGesture?: ComponentProps< @@ -451,6 +462,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const selectedBackgroundColor = useThemeColor("--color-user-bubble"); const { thread, onSelectThread, onArchiveThread, onDeleteThread } = props; + const { onRegenerateTitle } = props; const status = resolveThreadStatus(thread); const pr = useThreadPr(thread, props.projectCwd); const timestamp = relativeTime( @@ -470,6 +482,33 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + const handleRegenerateTitle = useCallback( + () => onRegenerateTitle?.(thread), + [onRegenerateTitle, thread], + ); + const showRegenerateTitle = + props.titleRegenerationSupported === true && onRegenerateTitle != null; + // A regeneration already running owns the title until it lands: disable the + // item and label it "Regenerating…" so a second long-press reads as inert + // rather than actionable (mirrors web's action menu). + const regeneratingTitle = thread.titleRegeneration != null; + const menuActions = useMemo( + () => [ + ...(showRegenerateTitle + ? [ + { + id: "regenerate-title", + title: regeneratingTitle ? "Regenerating…" : "Regenerate title", + image: "arrow.clockwise", + attributes: { disabled: regeneratingTitle }, + } satisfies MenuAction, + ] + : []), + THREAD_ROW_MENU_ARCHIVE, + THREAD_ROW_MENU_DELETE, + ], + [regeneratingTitle, showRegenerateTitle], + ); const primaryAction = useMemo( () => ({ accessibilityLabel: `Archive ${thread.title}`, @@ -481,10 +520,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "delete") handleDelete(); }, - [handleArchive, handleDelete], + [handleArchive, handleDelete, handleRegenerateTitle], ); const statusPill = effectiveStatus ? ( @@ -673,7 +713,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { // ControlPillMenu injects onLongPress into the row and anchors the // token-styled dropdown to it; taps and swipes are untouched. diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index eb618146172e..2bf11c4cd993 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -345,6 +345,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; + /** Adds a "Regenerate title" menu item when provided AND supported. */ + readonly onRegenerateTitle?: (thread: EnvironmentThreadShell) => void; + readonly titleRegenerationSupported?: boolean; readonly onPinThread: (thread: EnvironmentThreadShell) => void; readonly onUnpinThread: (thread: EnvironmentThreadShell) => void; /** False on environments whose server predates thread.settle/unsettle: @@ -388,6 +391,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onUnsnoozeThread, onUnsettleThread, onArchiveThread, + onRegenerateTitle, onPinThread, onUnpinThread, onMovePinnedThread, @@ -434,6 +438,33 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [onMovePinnedThread, thread], ); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + const handleRegenerateTitle = useCallback( + () => onRegenerateTitle?.(thread), + [onRegenerateTitle, thread], + ); + // Regenerate title leads every variant of the row menu, above the lifecycle + // and delete items, so both surfaces (this list and the v1 list) expose it + // regardless of settle/snooze/pin state. + const showRegenerateTitle = + props.titleRegenerationSupported === true && onRegenerateTitle != null; + // A regeneration already running owns the title until it lands: disable the + // item and label it "Regenerating…" so a second long-press reads as inert + // rather than actionable (mirrors web's action menu). + const regeneratingTitle = thread.titleRegeneration != null; + const titleMenuItems = useMemo( + () => + showRegenerateTitle + ? [ + { + id: "regenerate-title", + title: regeneratingTitle ? "Regenerating…" : "Regenerate title", + image: "arrow.clockwise", + attributes: { disabled: regeneratingTitle }, + }, + ] + : [], + [regeneratingTitle, showRegenerateTitle], + ); // Swipe: the v2 primary action is the lifecycle transition. Every settled // row can un-settle — explicit settles clear the override, auto-settled @@ -525,6 +556,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "settle") handleSettle(); if (nativeEvent.event === "unsettle") handleUnsettle(); if (nativeEvent.event === "unsnooze") handleUnsnooze(); @@ -551,6 +583,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleMovePinnedDown, handleMovePinnedUp, handlePin, + handleRegenerateTitle, handleSettle, handleSnooze, handleUnpin, @@ -885,8 +918,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { > {(close) => (