| ... | ... | @@ -2269,22 +2269,28 @@ export default function Sidebar() { |
| 2269 | 2269 | ); |
| 2270 | 2270 | // Drag-to-reorder for the pinned block. A drop computes ONE fractional key |
| 2271 | 2271 | // 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. |
| 2279 | 2283 | const pinnedDndSensors = useSensors( |
| 2280 | 2284 | useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), |
| 2281 | 2285 | ); |
| 2282 | 2286 | const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{ |
| 2283 | 2287 | 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. */ |
| 2287 | 2290 | 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>; |
| 2288 | 2294 | } | null>(null); |
| 2289 | 2295 | const orderedPinnedThreads = useMemo(() => { |
| 2290 | 2296 | if (optimisticPinnedOrder === null) return pinnedThreads; |
| ... | ... | @@ -2303,24 +2309,32 @@ export default function Sidebar() { |
| 2303 | 2309 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), |
| 2304 | 2310 | ); |
| 2305 | 2311 | // 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. |
| 2312 | 2319 | const membershipChanged = |
| 2313 | 2320 | canonicalKeys.length !== optimisticPinnedOrder.order.length || |
| 2314 | 2321 | 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, |
| 2319 | 2333 | ); |
| 2320 | 2334 | const orderConfirmed = |
| 2321 | 2335 | !membershipChanged && |
| 2322 | 2336 | canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]); |
| 2323 | | if (membershipChanged || anyKeyLanded || orderConfirmed) { |
| 2337 | if (membershipChanged || foreignKeyLanded || allAssignmentsLanded || orderConfirmed) { |
| 2324 | 2338 | setOptimisticPinnedOrder(null); |
| 2325 | 2339 | } |
| 2326 | 2340 | }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); |
| ... | ... | @@ -2390,7 +2404,13 @@ export default function Sidebar() { |
| 2390 | 2404 | movedId: activeKey, |
| 2391 | 2405 | }); |
| 2392 | 2406 | 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 | }); |
| 2394 | 2414 | void (async () => { |
| 2395 | 2415 | // Sequential, stop on first failure. There is deliberately no |
| 2396 | 2416 | // rollback: every key write is a complete, valid placement on its |
| ... | ... | @@ -2404,8 +2424,12 @@ export default function Sidebar() { |
| 2404 | 2424 | scopeThreadRef(thread.environmentId, thread.id), |
| 2405 | 2425 | assignment.orderKey, |
| 2406 | 2426 | ); |
| 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. |
| 2408 | 2431 | setOptimisticPinnedOrder(null); |
| 2432 | if (isAtomCommandInterrupted(result)) return; |
| 2409 | 2433 | const error = squashAtomCommandFailure(result); |
| 2410 | 2434 | toastManager.add( |
| 2411 | 2435 | stackedThreadToast({ |