From c6dcb1f6a2ecad936fdf9ade00d312c41844a96a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 16:20:21 -0700 Subject: [PATCH 1/3] fix(mobile): drafts swipe to discard and every row move animates Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/home/HomeScreen.tsx | 5 + .../features/home/thread-swipe-actions.tsx | 3 +- .../home/usePendingTaskListActions.ts | 20 ++- .../src/features/home/useThreadListActions.ts | 26 ++-- .../threads/ThreadNavigationSidebar.tsx | 4 + .../features/threads/thread-list-v2-items.tsx | 122 ++++++++++++------ 6 files changed, 127 insertions(+), 53 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index afa23370fe33..01c5292813ec 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -591,6 +591,11 @@ export function HomeScreen(props: HomeScreenProps) { showTrailingDivider={item.showTrailingDivider} onSelectPendingTask={props.onSelectPendingTask} onDeletePendingTask={props.onDeletePendingTask} + fullSwipeWidth={fullSwipeWidth} + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + simultaneousSwipeGesture={homeScrollGesture} + activationKey={item.key} /> ); } diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index a320e7b21e37..33c4a44d0524 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -62,6 +62,7 @@ interface ThreadSwipeAction { readonly accessibilityLabel: string; readonly icon: ComponentProps["name"]; readonly label: string; + readonly destructive?: boolean; readonly menu?: { readonly actions: MenuAction[]; readonly onPressAction: NonNullable["onPressAction"]>; @@ -726,7 +727,7 @@ export function ThreadSwipeActions(props: { void; @@ -37,10 +38,15 @@ export function usePendingTaskListActions(): { text: "Discard", style: "destructive", onPress: () => { - clearComposerDraftContent(pendingTask.draftKey, { - clearModelSelection: true, - clearWorkspaceSelection: true, - }); + void withThreadDismissal( + pendingTask.key, + async () => + clearComposerDraftContent(pendingTask.draftKey, { + clearModelSelection: true, + clearWorkspaceSelection: true, + }), + () => true, + ); }, }, ]); @@ -58,7 +64,11 @@ export function usePendingTaskListActions(): { // Release the edit lock only after removal succeeds, and only if // it is held for THIS task — clearing it up front (or for another // task) would let the drain deliver a mid-edit payload. - void removeThreadOutboxMessage(pendingTask.message) + void withThreadDismissal( + pendingTask.key, + () => removeThreadOutboxMessage(pendingTask.message).then(() => true), + () => true, + ) .then(() => releaseEditingQueuedMessage(pendingTask.message.messageId)) .catch((error) => { Dialog.alert( diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 837887a73d6e..0f18f24923cc 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -456,10 +456,15 @@ export function useThreadListActions(): { // Pinning clears snooze, so older Settled or Snoozed undos would fight this pin. ThreadUndo.invalidate("settle", scopedThreadKey(thread.environmentId, thread.id)); ThreadUndo.invalidate("snooze", scopedThreadKey(thread.environmentId, thread.id)); - const result = await pinMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id, ...(orderKey !== undefined ? { orderKey } : {}) }, - }); + const result = await withThreadDismissal( + scopedThreadKey(thread.environmentId, thread.id), + () => + pinMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, ...(orderKey !== undefined ? { orderKey } : {}) }, + }), + (result) => result._tag === "Success", + ); if (result._tag === "Failure") { const error = Cause.squash(result.cause); Dialog.alert( @@ -484,10 +489,15 @@ export function useThreadListActions(): { selectionHaptic(); const plan = ThreadUndo.planThreadUndo("unpin", thread); const claim = ThreadUndo.begin("pin", scopedThreadKey(thread.environmentId, thread.id)); - const result = await unpinMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id }, - }); + const result = await withThreadDismissal( + scopedThreadKey(thread.environmentId, thread.id), + () => + unpinMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }), + (result) => result._tag === "Success", + ); if (result._tag === "Failure") { claim.finish(); const error = Cause.squash(result.cause); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 9813f0d46333..96e80c44a72f 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -585,6 +585,10 @@ function ThreadNavigationSidebarPane( showPendingDivider={item.showPendingDivider} onSelectPendingTask={props.onOpenPendingTask} onDeletePendingTask={confirmDeletePendingTask} + fullSwipeWidth={props.width - 20} + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + simultaneousSwipeGesture={sidebarScrollGesture} /> ); } 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 d072c17aa719..4fb1275b8953 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -325,10 +325,34 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props readonly showTrailingDivider?: boolean; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; + readonly fullSwipeWidth?: number; + readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; + readonly onSwipeableClose: (methods: SwipeableMethods) => void; + readonly activationKey?: string; + readonly simultaneousSwipeGesture?: ComponentProps["simultaneousWith"]; }) { const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; + const { width: windowWidth } = useWindowDimensions(); + const dormant = useSwipeRowDormant(props.activationKey); + const theme = useUniwindTheme(); const sidebarPane = props.pane === "sidebar"; + const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, false); const isDraft = pendingTask.kind === "draft"; + const deleteLabel = isDraft ? "Discard" : "Delete"; + const handleDelete = useCallback( + () => onDeletePendingTask(pendingTask), + [onDeletePendingTask, pendingTask], + ); + const deleteAction = useMemo( + () => ({ + accessibilityLabel: `${deleteLabel} ${pendingTask.title}`, + destructive: true, + icon: "trash" as const, + label: deleteLabel, + onPress: handleDelete, + }), + [deleteLabel, handleDelete, pendingTask.title], + ); const projectTitle = props.projectTitle ?? props.project?.title ?? pendingTask.projectTitle ?? ""; const branch = pendingTask.branch; @@ -442,45 +466,65 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props {props.showPendingDivider ? ( ) : null} - - onSelectPendingTask(pendingTask)} - style={ - sidebarPane - ? { - borderRadius: SIDEBAR_V2_ROW_RADIUS, - paddingHorizontal: 12, - paddingVertical: 10, - } - : undefined - } - > - {sidebarPane ? ( - rowContent - ) : ( - - {rowContent} - {props.showTrailingDivider !== false ? ( - - ) : null} - - )} - - + {() => ( + + onSelectPendingTask(pendingTask)} + style={ + sidebarPane + ? { + borderRadius: SIDEBAR_V2_ROW_RADIUS, + paddingHorizontal: 12, + paddingVertical: 10, + } + : undefined + } + > + {sidebarPane ? ( + rowContent + ) : ( + + {rowContent} + {props.showTrailingDivider !== false ? ( + + ) : null} + + )} + + + )} + ); }); @@ -1334,7 +1378,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onSwipeableWillOpen={props.onSwipeableWillOpen} primaryAction={primaryAction} secondaryAction={secondaryAction} - resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}:${thread.settledAt}:${thread.unsettledAt}:${thread.snoozedUntil}`} + resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}:${thread.settledAt}:${thread.unsettledAt}:${thread.snoozedUntil}:${thread.pinnedAt}`} simultaneousWith={props.simultaneousSwipeGesture} threadTitle={thread.title} > From d82041a7daa2b9f7508de1000d45851d9138e023 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 19:05:02 -0700 Subject: [PATCH 2/3] fix(mobile): hold pending-task delivery while its delete animates The row now animates out before the outbox removal runs, so a reconnect in that window could deliver a task the user just deleted. Hold delivery from confirmation, keep the row when removal fails, and only drop a lock the editor already held once removal succeeds. Co-Authored-By: Claude Opus 5.5 --- .../home/usePendingTaskListActions.ts | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/home/usePendingTaskListActions.ts b/apps/mobile/src/features/home/usePendingTaskListActions.ts index aaf4523fdb72..5d7e28190744 100644 --- a/apps/mobile/src/features/home/usePendingTaskListActions.ts +++ b/apps/mobile/src/features/home/usePendingTaskListActions.ts @@ -5,7 +5,12 @@ import { useCallback } from "react"; import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal"; import { clearComposerDraftContent } from "../../state/use-composer-drafts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; -import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { + editingQueuedMessageIdsAtom, + holdEditingQueuedMessage, + releaseEditingQueuedMessage, +} from "../../state/use-thread-outbox"; import { withThreadDismissal } from "./thread-dismissal"; export function usePendingTaskListActions(): { @@ -61,16 +66,25 @@ export function usePendingTaskListActions(): { text: "Delete", style: "destructive", onPress: () => { - // Release the edit lock only after removal succeeds, and only if - // it is held for THIS task — clearing it up front (or for another - // task) would let the drain deliver a mid-edit payload. + // The row animates out before removal runs, so hold delivery from + // confirmation on: a reconnect must not send a task being deleted. + // A lock the editor already held stays held unless removal succeeds. + const messageId = pendingTask.message.messageId; + const heldByEditor = appAtomRegistry.get(editingQueuedMessageIdsAtom)[messageId]; + holdEditingQueuedMessage(messageId); + const releaseUnlessEditing = () => { + if (!heldByEditor) releaseEditingQueuedMessage(messageId); + }; void withThreadDismissal( pendingTask.key, - () => removeThreadOutboxMessage(pendingTask.message).then(() => true), - () => true, + () => removeThreadOutboxMessage(pendingTask.message), + (removed) => removed, ) - .then(() => releaseEditingQueuedMessage(pendingTask.message.messageId)) + .then((removed) => + removed ? releaseEditingQueuedMessage(messageId) : releaseUnlessEditing(), + ) .catch((error) => { + releaseUnlessEditing(); Dialog.alert( "Could not delete pending task", error instanceof Error ? error.message : "The pending task could not be removed.", From 6547592f30602dd0bab4499d04018077d1366fe2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 19:21:59 -0700 Subject: [PATCH 3/3] fix(mobile): delete a pending task open in the editor without the slide-out The editor shares the delivery hold, so closing it during the row animation would release the delete's hold and let the drain send the task. Remove it immediately in that case, as before. Co-Authored-By: Claude Opus 5.5 --- .../src/features/home/usePendingTaskListActions.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/home/usePendingTaskListActions.ts b/apps/mobile/src/features/home/usePendingTaskListActions.ts index 5d7e28190744..ff3572811cce 100644 --- a/apps/mobile/src/features/home/usePendingTaskListActions.ts +++ b/apps/mobile/src/features/home/usePendingTaskListActions.ts @@ -68,17 +68,20 @@ export function usePendingTaskListActions(): { onPress: () => { // The row animates out before removal runs, so hold delivery from // confirmation on: a reconnect must not send a task being deleted. - // A lock the editor already held stays held unless removal succeeds. + // The hold is shared with the editor, which could release it + // mid-animation, so a task open in the editor is removed at once + // and keeps its hold unless removal succeeds. const messageId = pendingTask.message.messageId; const heldByEditor = appAtomRegistry.get(editingQueuedMessageIdsAtom)[messageId]; holdEditingQueuedMessage(messageId); const releaseUnlessEditing = () => { if (!heldByEditor) releaseEditingQueuedMessage(messageId); }; - void withThreadDismissal( - pendingTask.key, - () => removeThreadOutboxMessage(pendingTask.message), - (removed) => removed, + const remove = () => removeThreadOutboxMessage(pendingTask.message); + void ( + heldByEditor + ? remove() + : withThreadDismissal(pendingTask.key, remove, (removed) => removed) ) .then((removed) => removed ? releaseEditingQueuedMessage(messageId) : releaseUnlessEditing(),