Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function HomeRouteScreen() {
unpinThread,
movePinnedThread,
unsettleThread,
regenerateThreadTitle,
} = useThreadListActions();
const pendingTasks = usePendingNewTasks();
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
Expand Down Expand Up @@ -165,6 +166,7 @@ export function HomeRouteScreen() {
}
onArchiveThread={archiveThread}
onDeleteThread={confirmDeleteThread}
onRegenerateTitle={regenerateThreadTitle}
onSettleThread={settleThread}
onSnoozeThread={snoozeThread}
onUnsnoozeThread={unsnoozeThread}
Expand Down
18 changes: 18 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;
readonly onSnoozeThread: (
Expand Down Expand Up @@ -615,6 +616,15 @@ export function HomeScreen(props: HomeScreenProps) {
}
return supported;
}, [serverConfigs]);
const titleRegenerationEnvironmentIds = useMemo(() => {
const supported = new Set<EnvironmentId>();
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.
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -854,13 +866,15 @@ export function HomeScreen(props: HomeScreenProps) {
projectByKey,
projectCwdByKey,
props.onArchiveThread,
props.onRegenerateTitle,
props.onDeletePendingTask,
props.onSelectPendingTask,
props.onSelectThread,
props.savedConnectionsById,
serverConfigs,
settlementEnvironmentIds,
snoozeEnvironmentIds,
titleRegenerationEnvironmentIds,
threadListV2Items,
threadSearchMatchByKey,
toggleSettledShelf,
Expand Down Expand Up @@ -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}
Expand All @@ -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,
],
Expand Down
59 changes: 59 additions & 0 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ThreadListAction, string> = {
Expand Down Expand Up @@ -227,13 +236,18 @@ export function useThreadListActions(): {
thread: EnvironmentThreadShell,
direction: "up" | "down",
) => Promise<boolean>;
readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;
} {
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<string>());
const regenerateTitleInFlightThreadKeys = useRef(new Set<string>());

const archiveThread = useCallback(
(thread: EnvironmentThreadShell) => {
Expand Down Expand Up @@ -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 {
Expand All @@ -497,6 +555,7 @@ export function useThreadListActions(): {
pinThread,
unpinThread,
movePinnedThread,
regenerateThreadTitle,
};
}

Expand Down
16 changes: 16 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ function ThreadNavigationSidebarPane(
pinThread,
unpinThread,
movePinnedThread,
regenerateThreadTitle,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
const pendingTasks = usePendingNewTasks();
Expand Down Expand Up @@ -505,6 +506,15 @@ function ThreadNavigationSidebarPane(
}
return supported;
}, [serverConfigs]);
const titleRegenerationEnvironmentIds = useMemo(() => {
const supported = new Set<EnvironmentId>();
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(() => {
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -1097,6 +1111,8 @@ function ThreadNavigationSidebarPane(
handleSwipeableWillOpen,
movePinnedThread,
openPendingTask,
regenerateThreadTitle,
titleRegenerationEnvironmentIds,
pinReorderEnvironmentIds,
pinThread,
pinningEnvironmentIds,
Expand Down
52 changes: 46 additions & 6 deletions apps/mobile/src/features/threads/thread-list-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<
Expand All @@ -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(
Expand All @@ -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<MenuAction[]>(
() => [
...(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}`,
Expand All @@ -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 ? (
Expand Down Expand Up @@ -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.
<ControlPillMenu
actions={THREAD_ROW_MENU_ACTIONS}
actions={menuActions}
onPressAction={handleMenuAction}
shouldOpenOnLongPress
>
Expand Down
Loading
Loading