| ... | @@ -2269,22 +2269,28 @@ export default function Sidebar() { | ... | @@ -2269,22 +2269,28 @@ export default function Sidebar() { |
| 2269 | ); | 2269 | ); |
| 2270 | // Drag-to-reorder for the pinned block. A drop computes ONE fractional key | 2270 | // Drag-to-reorder for the pinned block. A drop computes ONE fractional key |
| 2271 | // for the moved thread and sends it to that thread's own server (see | 2271 | // for the moved thread and sends it to that thread's own server (see |
| 2272 | // planPinnedReorder for the keyless-neighbor materialization case). The | 2272 | // planPinnedReorder for the keyless-neighbor materialization case, which |
| 2273 | // optimistic order keeps the card where it was dropped until the | 2273 | // instead rewrites every key in the section). The optimistic order keeps |
| 2274 | // confirming event round-trips; canonical order matching it releases the | 2274 | // the card where it was dropped until EVERY key the drop wrote is |
| 2275 | // override, and a failed write clears it (the card snaps back) with a toast. | 2275 | // reflected in canonical state — a section rewrite is several sequential |
| 2276 | // ANY membership change (new pin, unpin, snooze/wake) also releases it: | 2276 | // writes, and releasing on the first landed key would expose the |
| 2277 | // the override can't say where members it never saw belong, and holding it | 2277 | // half-written canonical order, reshuffling the block once per write. |
| 2278 | // would misplace them and launder the stale order into later drags. | 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 | const pinnedDndSensors = useSensors( | 2283 | const pinnedDndSensors = useSensors( |
| 2280 | useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), | 2284 | useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), |
| 2281 | ); | 2285 | ); |
| 2282 | const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{ | 2286 | const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{ |
| 2283 | readonly order: readonly string[]; | 2287 | readonly order: readonly string[]; |
| 2284 | /** pinOrderKey per thread as of the drop, so ANY landed write (ours | 2288 | /** pinOrderKey per thread as of the drop — the baseline that tells a |
| 2285 | confirming, or a concurrent one from another client) releases the | 2289 | concurrent client's write apart from one of our own landing. */ |
| 2286 | override rather than fighting canonical state. */ | | |
| 2287 | readonly keysAtDrop: ReadonlyMap<string, string | null>; | 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 | } | null>(null); | 2294 | } | null>(null); |
| 2289 | const orderedPinnedThreads = useMemo(() => { | 2295 | const orderedPinnedThreads = useMemo(() => { |
| 2290 | if (optimisticPinnedOrder === null) return pinnedThreads; | 2296 | if (optimisticPinnedOrder === null) return pinnedThreads; |
| ... | @@ -2303,24 +2309,32 @@ export default function Sidebar() { | ... | @@ -2303,24 +2309,32 @@ export default function Sidebar() { |
| 2303 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | 2309 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), |
| 2304 | ); | 2310 | ); |
| 2305 | // The override represents one drop against one snapshot of the world. | 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 | 2312 | // Release it when the world moves on: membership changed (pin/unpin/ |
| 2307 | // changed (pin/unpin/snooze/wake — the override can't say where members | 2313 | // snooze/wake — the override can't say where members it never saw |
| 2308 | // it never saw belong), a key changed (our write confirming, or a | 2314 | // belong), a key changed to something we did NOT write (a concurrent |
| 2309 | // concurrent client's reorder that must win), or canonical already | 2315 | // client's reorder that must win), every key we wrote has landed, or |
| 2310 | // matches. Holding it longer would misplace newcomers and launder the | 2316 | // canonical already matches. Releasing on the FIRST landed key instead |
| 2311 | // stale order into later drags. | 2317 | // of the last exposes the half-written order mid-materialization and |
| | 2318 | // the block visibly reshuffles once per write. |
| 2312 | const membershipChanged = | 2319 | const membershipChanged = |
| 2313 | canonicalKeys.length !== optimisticPinnedOrder.order.length || | 2320 | canonicalKeys.length !== optimisticPinnedOrder.order.length || |
| 2314 | canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key)); | 2321 | canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key)); |
| 2315 | const anyKeyLanded = canonical.some( | 2322 | const foreignKeyLanded = canonical.some((thread, index) => { |
| 2316 | (thread, index) => | 2323 | const threadKey = canonicalKeys[index]!; |
| 2317 | optimisticPinnedOrder.keysAtDrop.get(canonicalKeys[index]!) !== | 2324 | const currentKey = thread.pinOrderKey ?? null; |
| 2318 | (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 | const orderConfirmed = | 2334 | const orderConfirmed = |
| 2321 | !membershipChanged && | 2335 | !membershipChanged && |
| 2322 | canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]); | 2336 | canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]); |
| 2323 | if (membershipChanged || anyKeyLanded || orderConfirmed) { | 2337 | if (membershipChanged || foreignKeyLanded || allAssignmentsLanded || orderConfirmed) { |
| 2324 | setOptimisticPinnedOrder(null); | 2338 | setOptimisticPinnedOrder(null); |
| 2325 | } | 2339 | } |
| 2326 | }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); | 2340 | }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); |
| ... | @@ -2390,7 +2404,13 @@ export default function Sidebar() { | ... | @@ -2390,7 +2404,13 @@ export default function Sidebar() { |
| 2390 | movedId: activeKey, | 2404 | movedId: activeKey, |
| 2391 | }); | 2405 | }); |
| 2392 | if (assignments.length === 0) return; | 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 | void (async () => { | 2414 | void (async () => { |
| 2395 | // Sequential, stop on first failure. There is deliberately no | 2415 | // Sequential, stop on first failure. There is deliberately no |
| 2396 | // rollback: every key write is a complete, valid placement on its | 2416 | // rollback: every key write is a complete, valid placement on its |
| ... | @@ -2404,8 +2424,12 @@ export default function Sidebar() { | ... | @@ -2404,8 +2424,12 @@ export default function Sidebar() { |
| 2404 | scopeThreadRef(thread.environmentId, thread.id), | 2424 | scopeThreadRef(thread.environmentId, thread.id), |
| 2405 | assignment.orderKey, | 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 | setOptimisticPinnedOrder(null); | 2431 | setOptimisticPinnedOrder(null); |
| | 2432 | if (isAtomCommandInterrupted(result)) return; |
| 2409 | const error = squashAtomCommandFailure(result); | 2433 | const error = squashAtomCommandFailure(result); |
| 2410 | toastManager.add( | 2434 | toastManager.add( |
| 2411 | stackedThreadToast({ | 2435 | stackedThreadToast({ |