authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-05 18:58:43-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 18:58:43-07:00
logdb4d0a8e6e49d92a9efcd8d6f82bbed10e2bd748
tree8a684d45ab1dad073df81de05a4c0d4a5d40c03d
parent9422e2ee12fd3e3bfdf6fe5c377672badf7b20ca
signature Signed by PGP key B5690EEEBB952194

fix(web): sidebar drag and drop no longer snaps back (#16291)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

2 files changed, 66 insertions(+), 16 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+44
......@@ -2235,6 +2235,50 @@ describe("Working shelf (beta)", () => {
22352235 expect(resolveSidebarDropVerb("active", "working")).toBeNull();
22362236 });
22372237
2238 it("arranges the rows it can write when another row's server cannot store an order", () => {
2239 // None of the rows has a key yet, so the drop needs keys for its
2240 // neighbors too. "offline" sits on a server that cannot take them.
2241 const plan = planSidebarThreadDrop({
2242 activeKey: "a2",
2243 activeSection: "active",
2244 target: {
2245 section: "active",
2246 pinnedOrder: [],
2247 activeOrder: ["offline", "a2", "a1"],
2248 },
2249 pinnedOrder: [],
2250 pinnedKeysById: new Map(),
2251 activeOrder: ["offline", "a1", "a2"],
2252 activeKeysById: new Map([
2253 ["offline", null],
2254 ["a1", null],
2255 ["a2", null],
2256 ]),
2257 activeReorderableKeys: new Set(["a1", "a2"]),
2258 });
2259 expect(plan.kind).toBe("move-active");
2260 if (plan.kind !== "move-active") return;
2261 expect(plan.assignments.map(({ id }) => id)).toEqual(["a2", "a1"]);
2262 const [a2, a1] = plan.assignments.map(({ orderKey }) => orderKey);
2263 expect(a2! < a1!).toBe(true);
2264
2265 // A keyed row on that server still sorts by its key, so it stays a bound.
2266 const above = planSidebarThreadDrop({
2267 activeKey: "a2",
2268 activeSection: "active",
2269 target: { section: "active", pinnedOrder: [], activeOrder: ["a2", "offline"] },
2270 pinnedOrder: [],
2271 pinnedKeysById: new Map(),
2272 activeOrder: ["offline", "a2"],
2273 activeKeysById: new Map([
2274 ["offline", "m"],
2275 ["a2", "t"],
2276 ]),
2277 activeReorderableKeys: new Set(["a2"]),
2278 });
2279 expect(above.kind === "move-active" && above.assignments[0]!.orderKey < "m").toBe(true);
2280 });
2281
22382282 it("only changes lifecycle when the inbox is time-ordered", () => {
22392283 const base = {
22402284 pinnedOrder: ["p1"],
apps/web/src/components/Sidebar.logic.ts+22-16
......@@ -336,6 +336,24 @@ export function planSidebarThreadDrop(input: {
336336 if (input.supportsSettlement === false && (target.section === "settled" || activeSettled)) {
337337 return { kind: "none" };
338338 }
339 // Rows whose server cannot store an order (an older server, or a machine
340 // that is offline) are never written. Keyless ones sort outside the keyed
341 // run, so they leave the plan; keyed ones stay as bounds. Before, one keyless row
342 // refused every drop that needed fresh keys for its neighbors.
343 const arrange = (
344 order: readonly string[],
345 keysById: ReadonlyMap<string, string | null | undefined>,
346 writable: ReadonlySet<string> | undefined,
347 ) => {
348 if (!writable) return planPinnedReorder({ orderedIds: order, keysById, movedId: activeKey });
349 if (!writable.has(activeKey)) return null;
350 const assignments = planPinnedReorder({
351 orderedIds: order.filter((key) => writable.has(key) || keysById.get(key) != null),
352 keysById,
353 movedId: activeKey,
354 });
355 return assignments.every(({ id }) => writable.has(id)) ? assignments : null;
356 };
339357 switch (target.section) {
340358 case "active": {
341359 // Like the settled tail: threads can enter a time-ordered inbox, but
......@@ -360,14 +378,8 @@ export function planSidebarThreadDrop(input: {
360378 ) {
361379 return { kind: "none" };
362380 }
363 const assignments = planPinnedReorder({
364 orderedIds: order,
365 keysById: activeKeysById,
366 movedId: activeKey,
367 });
368 if (activeReorderableKeys && assignments.some(({ id }) => !activeReorderableKeys.has(id))) {
369 return { kind: "none" };
370 }
381 const assignments = arrange(order, activeKeysById, activeReorderableKeys);
382 if (assignments === null) return { kind: "none" };
371383 return {
372384 kind: "move-active",
373385 order,
......@@ -389,14 +401,8 @@ export function planSidebarThreadDrop(input: {
389401 ) {
390402 return { kind: "none" };
391403 }
392 const assignments = planPinnedReorder({
393 orderedIds: order,
394 keysById: pinnedKeysById,
395 movedId: activeKey,
396 });
397 if (reorderableKeys && assignments.some(({ id }) => !reorderableKeys.has(id))) {
398 return { kind: "none" };
399 }
404 const assignments = arrange(order, pinnedKeysById, reorderableKeys);
405 if (assignments === null) return { kind: "none" };
400406 if (activeSection === "pinned") {
401407 return assignments.length === 0
402408 ? { kind: "none" }