authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-08 21:46:27-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-08 18:46:27-07:00
log5208bdeb0db95063091a29f97af3548436c5f291
treeca904cdb3f9be70b72800c5758db8dbda48c1000
parenta6c9b41f902fba2a4137806c09e829935e91baac
signature Signed by PGP key B5690EEEBB952194

fix(web): pinned reorder no longer reshuffles while writes land (#5767)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

1 files changed, 47 insertions(+), 23 deletions(-)

apps/web/src/components/Sidebar.tsx+47-23
......@@ -2269,22 +2269,28 @@ export default function Sidebar() {
22692269 );
22702270 // Drag-to-reorder for the pinned block. A drop computes ONE fractional key
22712271 // for the moved thread and sends it to that thread's own server (see
2272 // planPinnedReorder for the keyless-neighbor materialization case). The
2273 // optimistic order keeps the card where it was dropped until the
2274 // confirming event round-trips; canonical order matching it releases the
2275 // override, and a failed write clears it (the card snaps back) with a toast.
2276 // ANY membership change (new pin, unpin, snooze/wake) also releases it:
2277 // the override can't say where members it never saw belong, and holding it
2278 // would misplace them and launder the stale order into later drags.
2272 // planPinnedReorder for the keyless-neighbor materialization case, which
2273 // instead rewrites every key in the section). The optimistic order keeps
2274 // the card where it was dropped until EVERY key the drop wrote is
2275 // reflected in canonical state — a section rewrite is several sequential
2276 // writes, and releasing on the first landed key would expose the
2277 // half-written canonical order, reshuffling the block once per write.
2278 // A failed write clears the override (the card snaps back) with a toast.
2279 // A key we did NOT write landing (a concurrent client's reorder that must
2280 // win) and ANY membership change (new pin, unpin, snooze/wake) also
2281 // release it: the override can't say where members it never saw belong,
2282 // and holding it would launder a stale order into later drags.
22792283 const pinnedDndSensors = useSensors(
22802284 useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
22812285 );
22822286 const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{
22832287 readonly order: readonly string[];
2284 /** pinOrderKey per thread as of the drop, so ANY landed write (ours
2285 confirming, or a concurrent one from another client) releases the
2286 override rather than fighting canonical state. */
2288 /** pinOrderKey per thread as of the drop — the baseline that tells a
2289 concurrent client's write apart from one of our own landing. */
22872290 readonly keysAtDrop: ReadonlyMap<string, string | null>;
2291 /** The keys this drop writes (one per planned assignment). The
2292 override holds until all of them appear in canonical state. */
2293 readonly assignedKeys: ReadonlyMap<string, string>;
22882294 } | null>(null);
22892295 const orderedPinnedThreads = useMemo(() => {
22902296 if (optimisticPinnedOrder === null) return pinnedThreads;
......@@ -2303,24 +2309,32 @@ export default function Sidebar() {
23032309 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
23042310 );
23052311 // The override represents one drop against one snapshot of the world.
2306 // Release it as soon as the world moves on in any way: membership
2307 // changed (pin/unpin/snooze/wake — the override can't say where members
2308 // it never saw belong), a key changed (our write confirming, or a
2309 // concurrent client's reorder that must win), or canonical already
2310 // matches. Holding it longer would misplace newcomers and launder the
2311 // stale order into later drags.
2312 // Release it when the world moves on: membership changed (pin/unpin/
2313 // snooze/wake — the override can't say where members it never saw
2314 // belong), a key changed to something we did NOT write (a concurrent
2315 // client's reorder that must win), every key we wrote has landed, or
2316 // canonical already matches. Releasing on the FIRST landed key instead
2317 // of the last exposes the half-written order mid-materialization and
2318 // the block visibly reshuffles once per write.
23122319 const membershipChanged =
23132320 canonicalKeys.length !== optimisticPinnedOrder.order.length ||
23142321 canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key));
2315 const anyKeyLanded = canonical.some(
2316 (thread, index) =>
2317 optimisticPinnedOrder.keysAtDrop.get(canonicalKeys[index]!) !==
2318 (thread.pinOrderKey ?? null),
2322 const foreignKeyLanded = canonical.some((thread, index) => {
2323 const threadKey = canonicalKeys[index]!;
2324 const currentKey = thread.pinOrderKey ?? null;
2325 if (currentKey === optimisticPinnedOrder.keysAtDrop.get(threadKey)) return false;
2326 return currentKey !== optimisticPinnedOrder.assignedKeys.get(threadKey);
2327 });
2328 const currentKeyByThreadKey = new Map(
2329 canonical.map((thread, index) => [canonicalKeys[index]!, thread.pinOrderKey ?? null]),
2330 );
2331 const allAssignmentsLanded = [...optimisticPinnedOrder.assignedKeys].every(
2332 ([threadKey, orderKey]) => currentKeyByThreadKey.get(threadKey) === orderKey,
23192333 );
23202334 const orderConfirmed =
23212335 !membershipChanged &&
23222336 canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]);
2323 if (membershipChanged || anyKeyLanded || orderConfirmed) {
2337 if (membershipChanged || foreignKeyLanded || allAssignmentsLanded || orderConfirmed) {
23242338 setOptimisticPinnedOrder(null);
23252339 }
23262340 }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]);
......@@ -2390,7 +2404,13 @@ export default function Sidebar() {
23902404 movedId: activeKey,
23912405 });
23922406 if (assignments.length === 0) return;
2393 setOptimisticPinnedOrder({ order: newOrder, keysAtDrop });
2407 setOptimisticPinnedOrder({
2408 order: newOrder,
2409 keysAtDrop,
2410 assignedKeys: new Map(
2411 assignments.map((assignment) => [assignment.id, assignment.orderKey]),
2412 ),
2413 });
23942414 void (async () => {
23952415 // Sequential, stop on first failure. There is deliberately no
23962416 // rollback: every key write is a complete, valid placement on its
......@@ -2404,8 +2424,12 @@ export default function Sidebar() {
24042424 scopeThreadRef(thread.environmentId, thread.id),
24052425 assignment.orderKey,
24062426 );
2407 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2427 if (result._tag === "Failure") {
2428 // Any failure — interrupted included — releases the override:
2429 // a key that never lands would otherwise hold it until some
2430 // unrelated world change came along.
24082431 setOptimisticPinnedOrder(null);
2432 if (isAtomCommandInterrupted(result)) return;
24092433 const error = squashAtomCommandFailure(result);
24102434 toastManager.add(
24112435 stackedThreadToast({