feat(mobile): arrange active threads from both thread lists#9730

merged3
sidebar/active-order
sidebar/mobile-active-order
openedSep 4, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 3d ago
1 unresolved thread · Jump to file
Selected thread: apps/mobile/src/features/threads/threadListV2.ts:206 — ### Move controls ignore reorder capability **Medium Severity** <!-- DESCRIPTION START --> `getThreadListV2OrderedSection` builds the move section from every live pinned or active row and never drops environments that lack `threadPinReorder` or `threadActiveReorder`. `canMoveUp` and `canMoveDown` then follow that list, so a row beside a keyless thread on an older server looks movable. The write path rejects that plan and surfaces an error. Pinned moves previously considered only reorder-capable threads, so this failure path did not exist. <!-- DESCRIPTION END --> <!-- BUGBOT_BUG_ID: 36e6cdcb-825d-47fe-b5cc-034017d25bbc --> <!-- LOCATIONS START apps/mobile/src/features/threads/threadListV2.ts#L173-L207 apps/mobile/src/features/home/HomeScreen.tsx#L648-L869 apps/mobile/src/features/home/useThreadListActions.ts#L478-L520 LOCATIONS END --> <details> <summary>Additional Locations (2)</summary> - [`apps/mobile/src/features/home/HomeScreen.tsx#L648-L869`](https://github.com/pingdotgg/t3code/blob/d7f93bfe63939919365f0f24dd6042ea910e95b8/apps/mobile/src/features/home/HomeScreen.tsx#L648-L869) - [`apps/mobile/src/features/home/useThreadListActions.ts#L478-L520`](https://github.com/pingdotgg/t3code/blob/d7f93bfe63939919365f0f24dd6042ea910e95b8/apps/mobile/src/features/home/useThreadListActions.ts#L478-L520) </details> <div><a href="https://cursor.com/open?link=eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9DVVJTT1IiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90Ojk1YzUwMDU4LTBjODctNDM1ZC04Mzk4LWMzNmJjNDhmODNkNiIsImVuY3J5cHRpb25LZXkiOiJVRjhoTTJVVmpsWTVhNmFUbUR6dFRFX2ZzNHFiLVo5bElRUFpEdU9kcC1ZIiwiYnJhbmNoIjoic2lkZWJhci9tb2JpbGUtYWN0aXZlLW9yZGVyIiwicmVwb093bmVyIjoicGluZ2RvdGdnIiwicmVwb05hbWUiOiJ0M2NvZGUiLCJwcm92aWRlciI6ImdpdGh1YiJ9fQ" target="_blank" rel="noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/fix-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/fix-in-cursor-light.png"><img alt="Fix in Cursor" width="115" height="28" src="https://cursor.com/assets/images/fix-in-cursor-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/agents?link=eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9XRUIiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90Ojk1YzUwMDU4LTBjODctNDM1ZC04Mzk4LWMzNmJjNDhmODNkNiIsImVuY3J5cHRpb25LZXkiOiJVRjhoTTJVVmpsWTVhNmFUbUR6dFRFX2ZzNHFiLVo5bElRUFpEdU9kcC1ZIiwiYnJhbmNoIjoic2lkZWJhci9tb2JpbGUtYWN0aXZlLW9yZGVyIiwicmVwb093bmVyIjoicGluZ2RvdGdnIiwicmVwb05hbWUiOiJ0M2NvZGUiLCJwck51bWJlciI6OTczMCwiY29tbWl0U2hhIjoiZDdmOTNiZmU2MzkzOTkxOTM2NWYwZjI0ZGQ2MDQyZWE5MTBlOTViOCIsInByb3ZpZGVyIjoiZ2l0aHViIn19" target="_blank" rel="noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/fix-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/fix-in-web-light.png"><img alt="Fix in Web" width="99" height="28" src="https://cursor.com/assets/images/fix-in-web-dark.png"></picture></a></div> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d7f93bfe63939919365f0f24dd6042ea910e95b8. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
0 / 11 viewed11 files changedLine totals unavailable

apps/mobile/src/features/threads/threadListV2.ts · Earlier revision d7f93bfe · right 206

Original diff
@@ -150,28 +156,54 @@ function parseTimestampMs(isoDate: string): number {
   return Number.isNaN(parsed) ? 0 : parsed;
 }
 
-/**
- * v2 sort: static order, newest anchor on top. Activity NEVER reorders the
- * list — a row holds its position between lifecycle transitions. The anchor
- * is creation time until an un-settle re-anchors it (see
- * activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the
- * top instead of sinking back to its creation-order slot. Mirrors web's
- * sortThreadsForSidebar.
- */
+/** The active order shared by web and native: new/reopened rows, then the
+    saved arrangement. Activity does not move a thread. */
 export function sortThreadsForListV2<
   T extends {
     readonly id: string;
     readonly createdAt: string;
     readonly unsettledAt?: string | null | undefined;
+    readonly activeOrderKey?: string | null | undefined;
+    readonly environmentId?: string | undefined;
   },
 >(threads: readonly T[]): T[] {
-  // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023
-  // change-by-copy array methods.
-  return [...threads].sort(
-    (left, right) =>
-      activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) ||
-      left.id.localeCompare(right.id),
-  );
+  return sortActiveThreadsByOrderKey(threads);
+}
+
+/** Canonical card section for Move up/down, independent of search or scope. */
+export function getThreadListV2OrderedSection(input: {
+  readonly threads: readonly EnvironmentThreadShell[];
+  readonly section: "pinned" | "active";
+  readonly pendingOrder?: PendingThreadOrder | null;
+  readonly now: string;
+  readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>;
+  readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
+}): EnvironmentThreadShell[] {
+  const threads = input.threads.filter((thread) => {
+    if (thread.archivedAt !== null) return false;
+    if (
+      (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) &&
+      thread.settledOverride === "settled"
+    ) {
+      return false;
+    }
+    if (
+      (input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true) &&
+      effectiveSnoozed(thread, { now: input.now })
+    ) {
+      return false;
+    }
+    return (thread.pinnedAt != null) === (input.section === "pinned");
+  });
+  const ordered =
+    input.section === "pinned"
+      ? sortPinnedThreadsByOrderKey(threads)
+      : sortActiveThreadsByOrderKey(threads);
+  const pending =
+    input.pendingOrder?.section === input.section
+      ? reconcilePendingThreadOrder(input.pendingOrder, ordered)
+      : null;
+  return applyPendingThreadOrder(ordered, input.section, pending);
C
cursorBotSep 4, 2026

Move controls ignore reorder capability

Medium Severity

getThreadListV2OrderedSection builds the move section from every live pinned or active row and never drops environments that lack threadPinReorder or threadActiveReorder. canMoveUp and canMoveDown then follow that list, so a row beside a keyless thread on an older server looks movable. The write path rejects that plan and surfaces an error. Pinned moves previously considered only reorder-capable threads, so this failure path did not exist.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit d7f93bfe63939919365f0f24dd6042ea910e95b8. Configure here.

J
Julius MarmingeSep 4, 2026

Fixed in db60895f3f97351263573cdf479cdf58ad6ebeef. The shared planner now checks every required key assignment against environment capabilities. Home, the navigation sidebar, and execution all use that planner, so impossible directions are disabled before a tap.

The full visible section remains intact: unsupported keyed neighbors can still anchor a supported move, while plans that must materialize an unsupported row's key are rejected. Focused coverage exercises both Active and pinned sections, unsupported keyless rows, and independent keyed moves around them. All 60 focused mobile tests and the mobile typecheck pass.