From 6eec3ce8e53520f031288ed64d6e058a7d50aecc Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Sat, 8 Aug 2026 00:30:51 +0200 Subject: [PATCH 1/2] feat(mobile): regenerate thread title from the row menu Add a capability-gated "Regenerate title" item to the thread row long-press menu, matching the web per-thread action menu. Shown only when the server advertises the threadTitleRegeneration capability (like settle/snooze/pin). - useThreadListActions gains regenerateThreadTitle: dispatches thread.meta.update { regenerateTitle: true } via the existing updateMetadata command, with a per-thread in-flight Set guard (same pattern as the snooze actions) so a rapid second tap can't start duplicate generation work. - Both the v1 (ThreadListRow) and v2 (ThreadListV2Row) row menus render the item across every variant; the capability is threaded through as titleRegenerationSupported alongside the existing settle/snooze/pin flags. Co-Authored-By: Claude Opus 4.8 --- .../src/features/home/HomeRouteScreen.tsx | 2 + apps/mobile/src/features/home/HomeScreen.tsx | 18 ++++++ .../src/features/home/useThreadListActions.ts | 59 +++++++++++++++++++ .../threads/ThreadNavigationSidebar.tsx | 16 +++++ .../features/threads/thread-list-items.tsx | 47 +++++++++++++-- .../features/threads/thread-list-v2-items.tsx | 31 ++++++++-- 6 files changed, 163 insertions(+), 10 deletions(-) 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..eab5991d7453 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,28 @@ 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; + const menuActions = useMemo( + () => [ + ...(showRegenerateTitle + ? [ + { + id: "regenerate-title", + title: "Regenerate title", + image: "arrow.clockwise", + } satisfies MenuAction, + ] + : []), + THREAD_ROW_MENU_ARCHIVE, + THREAD_ROW_MENU_DELETE, + ], + [showRegenerateTitle], + ); const primaryAction = useMemo( () => ({ accessibilityLabel: `Archive ${thread.title}`, @@ -481,10 +515,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 +708,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..342914571f2f 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,22 @@ 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; + const titleMenuItems = useMemo( + () => + showRegenerateTitle + ? [{ id: "regenerate-title", title: "Regenerate title", image: "arrow.clockwise" }] + : [], + [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 +545,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 +572,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleMovePinnedDown, handleMovePinnedUp, handlePin, + handleRegenerateTitle, handleSettle, handleSnooze, handleUnpin, @@ -885,8 +907,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { > {(close) => ( From 89ee43534f5672dd95bc04bea3af1251151db2c3 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Sat, 8 Aug 2026 00:57:30 +0200 Subject: [PATCH 2/2] fix(mobile): reflect in-flight regeneration in the row menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a title regeneration is running (thread.titleRegeneration != null), the menu item now disables itself and reads "Regenerating…" instead of staying enabled as "Regenerate title" — so a second long-press reads as inert rather than actionable, matching web's action menu. The handler's in-flight guard already no-oped the duplicate; this adds the missing visual feedback. Co-Authored-By: Claude Opus 4.8 --- .../src/features/threads/thread-list-items.tsx | 9 +++++++-- .../src/features/threads/thread-list-v2-items.tsx | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index eab5991d7453..487281f45b4c 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -488,21 +488,26 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ); 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: "Regenerate title", + title: regeneratingTitle ? "Regenerating…" : "Regenerate title", image: "arrow.clockwise", + attributes: { disabled: regeneratingTitle }, } satisfies MenuAction, ] : []), THREAD_ROW_MENU_ARCHIVE, THREAD_ROW_MENU_DELETE, ], - [showRegenerateTitle], + [regeneratingTitle, showRegenerateTitle], ); const primaryAction = useMemo( () => ({ 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 342914571f2f..2bf11c4cd993 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -447,12 +447,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { // 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: "Regenerate title", image: "arrow.clockwise" }] + ? [ + { + id: "regenerate-title", + title: regeneratingTitle ? "Regenerating…" : "Regenerate title", + image: "arrow.clockwise", + attributes: { disabled: regeneratingTitle }, + }, + ] : [], - [showRegenerateTitle], + [regeneratingTitle, showRegenerateTitle], ); // Swipe: the v2 primary action is the lifecycle transition. Every settled