From fe867c5ce640100e4c033da2bed858d1272a022e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 5 Oct 2026 18:11:36 -0700 Subject: [PATCH 1/2] fix(web): sidebar drops no longer snap back when one row cannot store an order Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/Sidebar.logic.test.ts | 28 +++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 35 ++++++++++--------- 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d8e4bab4187c..966dd2ea8bf7 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2235,6 +2235,34 @@ describe("Working shelf (beta)", () => { expect(resolveSidebarDropVerb("active", "working")).toBeNull(); }); + it("arranges the rows it can write when another row's server cannot store an order", () => { + // None of the rows has a key yet, so the drop needs keys for its + // neighbors too. "offline" sits on a server that cannot take them. + const plan = planSidebarThreadDrop({ + activeKey: "a2", + activeSection: "active", + target: { + section: "active", + pinnedOrder: [], + activeOrder: ["offline", "a2", "a1"], + }, + pinnedOrder: [], + pinnedKeysById: new Map(), + activeOrder: ["offline", "a1", "a2"], + activeKeysById: new Map([ + ["offline", null], + ["a1", null], + ["a2", null], + ]), + activeReorderableKeys: new Set(["a1", "a2"]), + }); + expect(plan.kind).toBe("move-active"); + if (plan.kind !== "move-active") return; + expect(plan.assignments.map(({ id }) => id)).toEqual(["a2", "a1"]); + const [a2, a1] = plan.assignments.map(({ orderKey }) => orderKey); + expect(a2! < a1!).toBe(true); + }); + it("only changes lifecycle when the inbox is time-ordered", () => { const base = { pinnedOrder: ["p1"], diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7e7010d59a42..e94ff27a2b35 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -336,6 +336,21 @@ export function planSidebarThreadDrop(input: { if (input.supportsSettlement === false && (target.section === "settled" || activeSettled)) { return { kind: "none" }; } + // Rows whose server cannot store an order (an older server, or a machine + // that is offline) keep their existing keys, and the drop arranges the rest. Before, + // one such row refused every drop that needed fresh keys for its neighbors. + const arrange = ( + order: readonly string[], + keysById: ReadonlyMap, + writable: ReadonlySet | undefined, + ) => + writable && !writable.has(activeKey) + ? null + : planPinnedReorder({ + orderedIds: writable ? order.filter((key) => writable.has(key)) : order, + keysById, + movedId: activeKey, + }); switch (target.section) { case "active": { // Like the settled tail: threads can enter a time-ordered inbox, but @@ -360,14 +375,8 @@ export function planSidebarThreadDrop(input: { ) { return { kind: "none" }; } - const assignments = planPinnedReorder({ - orderedIds: order, - keysById: activeKeysById, - movedId: activeKey, - }); - if (activeReorderableKeys && assignments.some(({ id }) => !activeReorderableKeys.has(id))) { - return { kind: "none" }; - } + const assignments = arrange(order, activeKeysById, activeReorderableKeys); + if (assignments === null) return { kind: "none" }; return { kind: "move-active", order, @@ -389,14 +398,8 @@ export function planSidebarThreadDrop(input: { ) { return { kind: "none" }; } - const assignments = planPinnedReorder({ - orderedIds: order, - keysById: pinnedKeysById, - movedId: activeKey, - }); - if (reorderableKeys && assignments.some(({ id }) => !reorderableKeys.has(id))) { - return { kind: "none" }; - } + const assignments = arrange(order, pinnedKeysById, reorderableKeys); + if (assignments === null) return { kind: "none" }; if (activeSection === "pinned") { return assignments.length === 0 ? { kind: "none" } From 7f7a67927f2be4616da70ac91ddbe969afa9e48b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 5 Oct 2026 18:17:57 -0700 Subject: [PATCH 2/2] fix(web): keyed rows on unwritable servers stay order bounds Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/Sidebar.logic.test.ts | 16 +++++++++++++ apps/web/src/components/Sidebar.logic.ts | 23 +++++++++++-------- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 966dd2ea8bf7..bf2a0c0cbd6f 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2261,6 +2261,22 @@ describe("Working shelf (beta)", () => { expect(plan.assignments.map(({ id }) => id)).toEqual(["a2", "a1"]); const [a2, a1] = plan.assignments.map(({ orderKey }) => orderKey); expect(a2! < a1!).toBe(true); + + // A keyed row on that server still sorts by its key, so it stays a bound. + const above = planSidebarThreadDrop({ + activeKey: "a2", + activeSection: "active", + target: { section: "active", pinnedOrder: [], activeOrder: ["a2", "offline"] }, + pinnedOrder: [], + pinnedKeysById: new Map(), + activeOrder: ["offline", "a2"], + activeKeysById: new Map([ + ["offline", "m"], + ["a2", "t"], + ]), + activeReorderableKeys: new Set(["a2"]), + }); + expect(above.kind === "move-active" && above.assignments[0]!.orderKey < "m").toBe(true); }); it("only changes lifecycle when the inbox is time-ordered", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index e94ff27a2b35..a8743024832f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -337,20 +337,23 @@ export function planSidebarThreadDrop(input: { return { kind: "none" }; } // Rows whose server cannot store an order (an older server, or a machine - // that is offline) keep their existing keys, and the drop arranges the rest. Before, - // one such row refused every drop that needed fresh keys for its neighbors. + // that is offline) are never written. Keyless ones sort outside the keyed + // run, so they leave the plan; keyed ones stay as bounds. Before, one keyless row + // refused every drop that needed fresh keys for its neighbors. const arrange = ( order: readonly string[], keysById: ReadonlyMap, writable: ReadonlySet | undefined, - ) => - writable && !writable.has(activeKey) - ? null - : planPinnedReorder({ - orderedIds: writable ? order.filter((key) => writable.has(key)) : order, - keysById, - movedId: activeKey, - }); + ) => { + if (!writable) return planPinnedReorder({ orderedIds: order, keysById, movedId: activeKey }); + if (!writable.has(activeKey)) return null; + const assignments = planPinnedReorder({ + orderedIds: order.filter((key) => writable.has(key) || keysById.get(key) != null), + keysById, + movedId: activeKey, + }); + return assignments.every(({ id }) => writable.has(id)) ? assignments : null; + }; switch (target.section) { case "active": { // Like the settled tail: threads can enter a time-ordered inbox, but