# Files changed · PR \#9731 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/9731/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(web\): drag threads across sections with consistent motion](https://git.cafe/pingdotgg/t3code/pull/9731.md)

Version: 9

Head commit: e9080323db5b6549d92a0c107c20fed6aae438fb

Base commit: a41887d423c1d7ce46ca90b6f78a22251917b4b7

Comparison base: a41887d423c1d7ce46ca90b6f78a22251917b4b7

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/src/components/Sidebar.drag.test.ts

Status: added; +616; −0

```diff
@@ -1,0 +1,616 @@
+import { describe, expect, it, vi } from "vite-plus/test";
+import { closestCenter, type CollisionDetection } from "@dnd-kit/core";
+import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable";
+import { createSidebarCollisionDetection, createSidebarSortingStrategy } from "./Sidebar.drag";
+import {
+  sidebarListItemId,
+  sidebarMarkerId,
+  type SidebarListItem,
+  type SidebarListMarker,
+  type SidebarSection,
+} from "./Sidebar.logic";
+
+const thread = (key: string, section: SidebarSection): SidebarListItem => ({
+  kind: "thread",
+  key,
+  section,
+});
+const marker = (marker: SidebarListMarker): SidebarListItem => ({ kind: "marker", marker });
+const pinnedHeader = marker("pinned-header");
+const divider = marker("pinned-divider");
+const settledHeader = marker("settled-header");
+const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 };
+
+function layout(
+  items: readonly SidebarListItem[],
+  active: string,
+  over: string,
+  scale = 1,
+  cardHeight = 82,
+) {
+  let top = 100;
+  const rects = items.map((item) => {
+    const height =
+      item.kind === "thread"
+        ? (item.section === "pinned" || item.section === "active" ? cardHeight : 36) * scale
+        : item.marker === "pinned-header" || item.marker === "pinned-divider"
+          ? 0
+          : (item.marker.endsWith("placeholder") ? 36 : 32) * scale;
+    const rect = { top, height, bottom: top + height, left: 0, right: 260, width: 260 };
+    top += height + 1;
+    return rect;
+  });
+  const activeIndex = items.findIndex((item) => sidebarListItemId(item) === active);
+  return {
+    activeIndex,
+    overIndex: items.findIndex((item) => sidebarListItemId(item) === over),
+    activeNodeRect: rects[activeIndex]!,
+    rects,
+    index: 0,
+  } satisfies Parameters<SortingStrategy>[0];
+}
+
+function preview(
+  input: Parameters<typeof createSidebarSortingStrategy>[0],
+  active: string,
+  over: string,
+  scale = 1,
+) {
+  const strategy = createSidebarSortingStrategy(input);
+  const args = layout(input.items, active, over, scale);
+  return new Map(
+    input.items.map((item, index) => [sidebarListItemId(item), strategy({ ...args, index })]),
+  );
+}
+
+describe("sidebar collision detection", () => {
+  function collisionArgs(blockedAboveSource = false) {
+    const rows = [thread("source", "active"), thread("blocked", "active")];
+    const items = [
+      pinnedHeader,
+      divider,
+      ...(blockedAboveSource ? rows.toReversed() : rows),
+      settledHeader,
+      marker("settled-placeholder"),
+    ];
+    const { rects, activeIndex, overIndex } = layout(items, "source", "blocked");
+    const collisionRect = rects[overIndex]!;
+    return {
+      active: {
+        id: "source",
+        data: { current: {} },
+        rect: { current: { initial: rects[activeIndex]!, translated: collisionRect } },
+      },
+      collisionRect,
+      droppableRects: new Map(items.map((item, index) => [sidebarListItemId(item), rects[index]!])),
+      droppableContainers: items.map((item, index) => ({
+        id: sidebarListItemId(item),
+        key: sidebarListItemId(item),
+        disabled: false,
+        data: { current: {} },
+        node: { current: null },
+        rect: { current: rects[index]! },
+      })),
+      pointerCoordinates: null,
+    } satisfies Parameters<CollisionDetection>[0];
+  }
+
+  it.each([
+    [false, sidebarMarkerId("settled-header")],
+    [true, sidebarMarkerId("pinned-divider")],
+  ] as const)(
+    "rejects unsupported Active instead of selecting %s / %s",
+    (blockedAboveSource, nearbyTarget) => {
+      const args = collisionArgs(blockedAboveSource);
+      const detector = createSidebarCollisionDetection((id) => id !== "blocked");
+      const filtered = closestCenter({
+        ...args,
+        droppableContainers: args.droppableContainers.filter(
+          (container) => container.id !== "blocked",
+        ),
+      });
+      expect(filtered[0]?.id).toBe(nearbyTarget);
+      expect(detector(args).map((collision) => collision.id)).toEqual(["source"]);
+    },
+  );
+
+  it("selects the nearest supported target", () => {
+    const detector = createSidebarCollisionDetection(() => true);
+    expect(detector(collisionArgs())[0]?.id).toBe("blocked");
+  });
+
+  function clampedArgs() {
+    const args = collisionArgs();
+    const pinned = args.droppableRects.get(sidebarMarkerId("pinned-header"))!;
+    const source = args.droppableRects.get("source")!;
+    const collisionRect = {
+      ...source,
+      top: pinned.top - 8,
+      bottom: pinned.top - 8 + source.height,
+    };
+    return {
+      ...args,
+      active: {
+        ...args.active,
+        rect: { current: { initial: source, translated: collisionRect } },
+      },
+      collisionRect,
+      pointerCoordinates: { x: pinned.left + pinned.width / 2, y: pinned.top + 8 },
+    };
+  }
+
+  it("reaches empty Pins with an upward pointer while the card is clamped at the top", () => {
+    const args = clampedArgs();
+    const detector = createSidebarCollisionDetection(() => true, {
+      emptyPins: true,
+      activationY: args.pointerCoordinates.y + 6,
+    });
+    expect(args.droppableRects.get(sidebarMarkerId("pinned-header"))?.height).toBe(0);
+    expect(closestCenter(args)[0]?.id).toBe("source");
+    expect(detector(args)[0]?.id).toBe(sidebarMarkerId("pinned-header"));
+  });
+
+  it.each([
+    { reason: "below the boundary cue", x: 130, y: 109, activationY: 140, emptyPins: true },
+    { reason: "left of the list", x: -1, y: 108, activationY: 140, emptyPins: true },
+    { reason: "right of the list", x: 261, y: 108, activationY: 140, emptyPins: true },
+    { reason: "less than 6px upward", x: 130, y: 108, activationY: 113, emptyPins: true },
+    { reason: "without an activation point", x: 130, y: 108, activationY: null, emptyPins: true },
+    { reason: "with populated Pins", x: 130, y: 108, activationY: 140, emptyPins: false },
+  ])("keeps ordinary collision behavior $reason", ({ x, y, activationY, emptyPins }) => {
+    const detector = createSidebarCollisionDetection(() => true, { emptyPins, activationY });
+    const args = { ...clampedArgs(), pointerCoordinates: { x, y } };
+    expect(detector(args)[0]?.id).toBe("source");
+  });
+
+  it("keeps ordinary collision behavior without pointer coordinates", () => {
+    const detector = createSidebarCollisionDetection(() => true, {
+      emptyPins: true,
+      activationY: 140,
+    });
+    expect(detector({ ...clampedArgs(), pointerCoordinates: null })[0]?.id).toBe("source");
+  });
+
+  it("validates the empty Pins override and caches an unsupported result", () => {
+    const isValid = vi.fn(() => false);
+    const detector = createSidebarCollisionDetection(isValid, {
+      emptyPins: true,
+      activationY: 140,
+    });
+    const args = clampedArgs();
+    expect(detector(args).map((collision) => collision.id)).toEqual(["source"]);
+    expect(detector(args).map((collision) => collision.id)).toEqual(["source"]);
+    expect(isValid.mock.calls).toEqual([[sidebarMarkerId("pinned-header")]]);
+  });
+
+  it("returns no collision if an unsupported target has no source fallback", () => {
+    const args = collisionArgs();
+    const detector = createSidebarCollisionDetection(() => false);
+    expect(
+      detector({
+        ...args,
+        droppableContainers: args.droppableContainers.filter(
+          (container) => container.id !== "source",
+        ),
+      }),
+    ).toEqual([]);
+  });
+
+  it("validates each hovered target once and always allows returning to the source", () => {
+    const args = collisionArgs();
+    const isValid = vi.fn((id: string) => id !== "blocked");
+    const detector = createSidebarCollisionDetection(isValid);
+    expect(detector(args)[0]?.id).toBe("source");
+    expect(
+      detector({
+        ...args,
+        collisionRect: {
+          ...args.collisionRect,
+          top: args.collisionRect.top + 3,
+          bottom: args.collisionRect.bottom + 3,
+        },
+      })[0]?.id,
+    ).toBe("source");
+    expect(detector({ ...args, collisionRect: args.droppableRects.get("source")! })[0]?.id).toBe(
+      "source",
+    );
+    expect(
+      detector({
+        ...args,
+        collisionRect: args.droppableRects.get(sidebarMarkerId("settled-placeholder"))!,
+      })[0]?.id,
+    ).toBe(sidebarMarkerId("settled-placeholder"));
+    expect(isValid.mock.calls).toEqual([["blocked"], [sidebarMarkerId("settled-placeholder")]]);
+  });
+});
+
+describe("sidebar drag projection", () => {
+  const pinned = [
+    pinnedHeader,
+    thread("p1", "pinned"),
+    thread("p2", "pinned"),
+    divider,
+    thread("a1", "active"),
+    settledHeader,
+    thread("s1", "settled"),
+  ];
+
+  it.each([
+    ["p1", "p2"],
+    ["p2", "p1"],
+  ])("preserves existing pinned transforms from %s to %s", (active, over) => {
+    const strategy = createSidebarSortingStrategy({
+      items: pinned,
+      settledOrder: [],
+      settledExpanded: true,
+    });
+    const args = layout(pinned, active, over);
+    for (let index = 0; index < pinned.length; index += 1) {
+      expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index }));
+    }
+  });
+
+  it("keeps the pinned header above the gap when a lower pin moves to the top", () => {
+    const result = preview(
+      { items: pinned, settledOrder: [], settledExpanded: true },
+      "p2",
+      sidebarMarkerId("pinned-header"),
+    );
+    expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
+    expect(result.get("p1")).toEqual({ ...stationary, y: 83 });
+    expect(result.get(sidebarMarkerId("pinned-divider"))).toEqual(stationary);
+    expect(result.get("a1")).toEqual(stationary);
+  });
+
+  it.each([
+    ["a1", "a2"],
+    ["a2", "a1"],
+  ])("uses pinned dragging behavior for Active from %s to %s", (active, over) => {
+    const items = [
+      pinnedHeader,
+      thread("p", "pinned"),
+      divider,
+      thread("a1", "active"),
+      thread("a2", "active"),
+      settledHeader,
+      thread("s", "settled"),
+    ];
+    const strategy = createSidebarSortingStrategy({
+      items,
+      settledOrder: [],
+      settledExpanded: true,
+    });
+    const args = layout(items, active, over);
+    for (let index = 0; index < items.length; index += 1) {
+      expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index }));
+    }
+  });
+
+  it("leaves canonically sorted settled peers in place", () => {
+    const items = [
+      pinnedHeader,
+      divider,
+      marker("active-placeholder"),
+      settledHeader,
+      thread("first", "settled"),
+      thread("second", "settled"),
+    ];
+    const result = preview(
+      { items, settledOrder: ["first", "second"], settledExpanded: true },
+      "second",
+      "first",
+    );
+    expect([...result.values()]).toEqual(items.map(() => stationary));
+  });
+
+  it.each([
+    [sidebarMarkerId("pinned-divider"), 0, 0],
+    ["a1", -83, 0],
+    ["a2", -83, -83],
+  ] as const)(
+    "opens the active pointer slot over %s without adding an empty pinned row",
+    (over, a1Offset, a2Offset) => {
+      const items = [
+        pinnedHeader,
+        thread("p", "pinned"),
+        divider,
+        thread("a1", "active"),
+        thread("a2", "active"),
+        settledHeader,
+        thread("s", "settled"),
+      ];
+      const result = preview({ items, settledOrder: [], settledExpanded: true }, "p", over);
+      expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
+      expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(-83);
+      expect(result.get("a1")?.y).toBe(a1Offset);
+      expect(result.get("a2")?.y).toBe(a2Offset);
+      expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(0);
+    },
+  );
+
+  it("keeps the pinned header above the first arriving pin", () => {
+    const items = [
+      pinnedHeader,
+      divider,
+      thread("a1", "active"),
+      thread("a2", "active"),
+      settledHeader,
+      thread("s", "settled"),
+    ];
+    const result = preview(
+      { items, settledOrder: [], settledExpanded: true },
+      "a2",
+      sidebarMarkerId("pinned-header"),
+    );
+    expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
+    expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(83);
+    expect(result.get("a1")?.y).toBe(83);
+    expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(0);
+  });
+
+  it.each([
+    ["p", -83, -37],
+    ["s", 0, 46],
+  ] as const)(
+    "replaces the empty Active target when %s enters",
+    (active, dividerOffset, settledOffset) => {
+      const items = [
+        pinnedHeader,
+        thread("p", "pinned"),
+        divider,
+        marker("active-placeholder"),
+        settledHeader,
+        thread("s", "settled"),
+      ];
+      const result = preview(
+        { items, settledOrder: [], settledExpanded: true },
+        active,
+        sidebarMarkerId("active-placeholder"),
+      );
+      expect(result.get(sidebarMarkerId("active-placeholder"))?.scaleY).toBe(0);
+      expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(dividerOffset);
+      expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(settledOffset);
+    },
+  );
+
+  it("uses the canonical settled rank and the destination's slim height", () => {
+    const items = [
+      pinnedHeader,
+      thread("p", "pinned"),
+      divider,
+      thread("a", "active"),
+      settledHeader,
+      thread("s1", "settled"),
+      thread("s2", "settled"),
+    ];
+    const result = preview(
+      { items, settledOrder: ["s1", "a", "s2"], settledExpanded: true },
+      "a",
+      "s2",
+    );
+    expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-46);
+    expect(result.get("s1")?.y).toBe(-46);
+    expect(result.get("s2")?.y).toBe(-9);
+  });
+
+  it.each([
+    ["a1", 83],
+    ["a2", 0],
+  ] as const)(
+    "reserves a full card at the pointer slot over %s when a slim row enters Active",
+    (over, firstOffset) => {
+      const items = [
+        pinnedHeader,
+        thread("p", "pinned"),
+        divider,
+        thread("a1", "active"),
+        thread("a2", "active"),
+        settledHeader,
+        thread("s", "settled"),
+      ];
+      const result = preview({ items, settledOrder: [], settledExpanded: true }, "s", over);
+      expect(result.get("a1")?.y).toBe(firstOffset);
+      expect(result.get("a2")?.y).toBe(83);
+      expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(83);
+    },
+  );
+
+  it("removes the snoozed header when its last row leaves", () => {
+    const items = [
+      pinnedHeader,
+      thread("p", "pinned"),
+      divider,
+      thread("a", "active"),
+      marker("snoozed-header"),
+      thread("z", "snoozed"),
+      settledHeader,
+      thread("s", "settled"),
+    ];
+    const result = preview({ items, settledOrder: [], settledExpanded: true }, "z", "a");
+    expect(result.get(sidebarMarkerId("snoozed-header"))?.scaleY).toBe(0);
+    expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(13);
+    expect(result.get("s")?.y).toBe(13);
+  });
+
+  it("keeps a collapsed settled target without inserting a hidden row", () => {
+    const items = [
+      pinnedHeader,
+      thread("p", "pinned"),
+      divider,
+      thread("a1", "active"),
+      thread("a2", "active"),
+      settledHeader,
+      marker("settled-placeholder"),
+    ];
+    const result = preview(
+      { items, settledOrder: [], settledExpanded: false },
+      "a2",
+      sidebarMarkerId("settled-placeholder"),
+    );
+    expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-83);
+    expect(result.get(sidebarMarkerId("settled-placeholder"))).toEqual({ ...stationary, y: -83 });
+  });
+
+  it("preserves a collapsed snoozed header while another section changes", () => {
+    const items = [
+      pinnedHeader,
+      thread("p", "pinned"),
+      divider,
+      thread("a", "active"),
+      marker("snoozed-header"),
+      settledHeader,
+      marker("settled-placeholder"),
+    ];
+    const result = preview(
+      { items, settledOrder: [], settledExpanded: false },
+      "a",
+      sidebarMarkerId("settled-placeholder"),
+    );
+    expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: -46 });
+  });
+
+  it("derives missing card geometry from the measured root scale", () => {
+    const items = [
+      pinnedHeader,
+      divider,
+      marker("active-placeholder"),
+      settledHeader,
+      thread("s", "settled"),
+    ];
+    const result = preview(
+      { items, settledOrder: [], settledExpanded: true },
+      "s",
+      sidebarMarkerId("pinned-header"),
+      0.75,
+    );
+    expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
+    expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(62.5);
+    expect(result.get(sidebarMarkerId("active-placeholder"))?.y).toBe(62.5);
+  });
+
+  it("updates the projection when the target or measured geometry changes", () => {
+    const strategy = createSidebarSortingStrategy({
+      items: pinned,
+      settledOrder: [],
+      settledExpanded: true,
+    });
+    const args = layout(pinned, "p1", "p1");
+    expect(strategy({ ...args, index: 2 })?.y).toBe(0);
+    expect(strategy({ ...args, index: 2, overIndex: 4 })?.y).toBe(-83);
+    const smaller = layout(pinned, "p1", "a1", 0.75);
+    expect(strategy({ ...smaller, index: 2 })?.y).toBe(-62.5);
+  });
+
+  it("uses measured placeholder sizing when card height differs from its default", () => {
+    const items = [
+      pinnedHeader,
+      thread("p", "pinned"),
+      divider,
+      thread("a", "active"),
+      settledHeader,
+      marker("settled-placeholder"),
+    ];
+    const strategy = createSidebarSortingStrategy({
+      items,
+      settledOrder: [],
+      settledExpanded: false,
+    });
+    const args = layout(items, "a", sidebarMarkerId("settled-placeholder"), 1, 78);
+    expect(strategy({ ...args, index: 4 })?.y).toBe(-42);
+  });
+
+  it("keeps the route row visible after a settled drop pushes it beyond the page", () => {
+    const items = [
+      pinnedHeader,
+      thread("p", "pinned"),
+      divider,
+      thread("a", "active"),
+      settledHeader,
+      thread("s", "settled"),
+    ];
+    const input = {
+      items,
+      settledOrder: ["a", "s", "hidden"],
+      settledExpanded: true,
+      settledVisibleCount: 1,
+    };
+    const withRoute = preview({ ...input, routeThreadKey: "s" }, "a", "s");
+    const withoutRoute = preview(input, "a", "s");
+    expect(withRoute.get("s")).toEqual({ ...stationary, y: -9 });
+    expect(withoutRoute.get("s")?.scaleY).toBe(0);
+  });
+
+  it("reserves the next page row when a visible settled thread leaves", () => {
+    const items = [
+      pinnedHeader,
+      thread("p", "pinned"),
+      divider,
+      thread("a", "active"),
+      settledHeader,
+      thread("s1", "settled"),
+      thread("route", "settled"),
+    ];
+    const result = preview(
+      {
+        items,
+        settledOrder: ["s1", "hidden", "route"],
+        settledExpanded: true,
+        settledVisibleCount: 1,
+        routeThreadKey: "route",
+      },
+      "s1",
+      "a",
+    );
+    expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(83);
+    expect(result.get("route")?.y).toBe(83);
+  });
+
+  it("keeps the dropped route thread visible in a collapsed settled shelf", () => {
+    const items = [
+      pinnedHeader,
+      thread("p", "pinned"),
+      divider,
+      thread("a", "active"),
+      settledHeader,
+      marker("settled-placeholder"),
+    ];
+    const result = preview(
+      {
+        items,
+        settledOrder: ["a", "hidden"],
+        settledExpanded: false,
+        settledVisibleCount: 1,
+        routeThreadKey: "a",
+      },
+      "a",
+      sidebarMarkerId("settled-placeholder"),
+    );
+    expect(result.get(sidebarMarkerId("settled-placeholder"))?.scaleY).toBe(0);
+    expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-46);
+  });
+
+  it("preserves hidden snoozed membership when the only rendered route row leaves", () => {
+    const items = [
+      pinnedHeader,
+      thread("p", "pinned"),
+      divider,
+      thread("a", "active"),
+      marker("snoozed-header"),
+      thread("z", "snoozed"),
+      settledHeader,
+      thread("s", "settled"),
+    ];
+    const result = preview(
+      {
+        items,
+        settledOrder: ["s"],
+        settledExpanded: true,
+        snoozedThreadCount: 2,
+      },
+      "z",
+      "a",
+    );
+    expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: 83 });
+    expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(46);
+  });
+});
```

## apps/web/src/components/Sidebar.drag.ts

Status: added; +175; −0

```diff
@@ -1,0 +1,175 @@
+import { closestCenter, type CollisionDetection } from "@dnd-kit/core";
+import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable";
+import {
+  resolveSidebarDropTarget,
+  sidebarListItemId,
+  sidebarMarkerId,
+  type SidebarListItem,
+  type SidebarListMarker,
+  type SidebarSection,
+} from "./Sidebar.logic";
+
+const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 };
+const hidden = { ...stationary, scaleY: 0 };
+type ThreadItem = Extract<SidebarListItem, { kind: "thread" }>;
+type Layout = Parameters<SortingStrategy>[0];
+
+/** Reject the nearest unsupported target without selecting another section.
+ * Recreate this detector when drop eligibility changes. */
+export function createSidebarCollisionDetection(
+  isValidTarget: (id: string) => boolean,
+  options: { emptyPins?: boolean; activationY?: number | null } = {},
+): CollisionDetection {
+  const validity = new Map<string, boolean>();
+  const pinnedHeaderId = sidebarMarkerId("pinned-header");
+  return (args) => {
+    let collisions = closestCenter(args);
+    const pinnedRect = options.emptyPins ? args.droppableRects.get(pinnedHeaderId) : undefined;
+    const pointer = args.pointerCoordinates;
+    // The card itself is clamped by the scroll container. An upward pointer
+    // gesture can still reach the empty pinned boundary without reserving a row.
+    if (
+      pinnedRect &&
+      pointer &&
+      options.activationY != null &&
+      pointer.y <= options.activationY - 6 &&
+      pointer.y <= pinnedRect.top + 8 &&
+      pointer.x >= pinnedRect.left &&
+      pointer.x <= pinnedRect.right
+    ) {
+      const pinned = collisions.find((collision) => collision.id === pinnedHeaderId);
+      if (pinned) {
+        collisions = [pinned, ...collisions.filter((collision) => collision !== pinned)];
+      }
+    }
+    const nearest = collisions[0];
+    if (!nearest || nearest.id === args.active.id) return collisions;
+    const id = String(nearest.id);
+    const valid = validity.get(id) ?? isValidTarget(id);
+    validity.set(id, valid);
+    return valid ? collisions : collisions.filter((collision) => collision.id === args.active.id);
+  };
+}
+
+/** Preview the committed section layout without moving or mounting DOM nodes.
+ * A zero scaleY marks rows/markers to hide while retaining their measured nodes. */
+export function createSidebarSortingStrategy(input: {
+  items: readonly SidebarListItem[];
+  settledOrder: readonly string[];
+  settledExpanded: boolean;
+  settledVisibleCount?: number;
+  routeThreadKey?: string | null;
+  snoozedThreadCount?: number;
+  cardHeight?: number;
+  slimHeight?: number;
+}): SortingStrategy {
+  const { items } = input;
+  const indices = new Map(items.map((item, index) => [sidebarListItemId(item), index]));
+  let previous: Pick<Layout, "rects" | "activeIndex" | "overIndex"> | undefined;
+  let transforms: ReturnType<SortingStrategy>[] | null = [];
+
+  function project({ rects, activeIndex, overIndex }: Layout) {
+    const active = items[activeIndex];
+    const over = items[overIndex];
+    if (active?.kind !== "thread" || !over || !rects[0]) return [];
+    const target = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over));
+    if (!target) return [];
+    if (target.section === active.section && over.kind === "thread")
+      return target.section === "settled" ? [] : null;
+    const groups: Record<SidebarSection, ThreadItem[]> = {
+      pinned: [],
+      active: [],
+      snoozed: [],
+      settled: [],
+    };
+    let cardHeight = input.cardHeight;
+    let slimHeight = input.slimHeight;
+    for (const [index, item] of items.entries()) {
+      if (item.kind === "marker") {
+        if (item.marker.endsWith("placeholder")) slimHeight ??= rects[index]?.height;
+        continue;
+      }
+      if (item.section === "pinned" || item.section === "active")
+        cardHeight ??= rects[index]?.height;
+      else slimHeight ??= rects[index]?.height;
+      if (item.key !== active.key) groups[item.section].push(item);
+    }
+    // Cards are 4.875rem + 0.25rem padding; slim rows/placeholders are h-9.
+    const scale = slimHeight !== undefined ? slimHeight / 36 : (cardHeight ?? 82) / 82;
+    cardHeight ??= 82 * scale;
+    slimHeight ??= 36 * scale;
+    const group = groups[target.section];
+    const order =
+      target.section === "pinned"
+        ? target.pinnedOrder
+        : target.section === "settled"
+          ? input.settledOrder
+          : target.activeOrder;
+    const ranks = new Map(order.map((key, index) => [key, index]));
+    const rank = ranks.get(active.key) ?? Number.POSITIVE_INFINITY;
+    const index = group.findIndex(
+      (item) => (ranks.get(item.key) ?? Number.POSITIVE_INFINITY) > rank,
+    );
+    group.splice(index < 0 ? group.length : index, 0, { ...active, section: target.section });
+    const settledOrder = (
+      input.settledOrder.length > 0 ? input.settledOrder : groups.settled.map((item) => item.key)
+    ).filter((key) => key !== active.key || target.section === "settled");
+    const visible = input.settledExpanded
+      ? settledOrder.slice(0, input.settledVisibleCount ?? settledOrder.length)
+      : [];
+    const routeKey = input.routeThreadKey;
+    if (routeKey && settledOrder.includes(routeKey) && !visible.includes(routeKey)) {
+      visible.push(routeKey);
+    }
+    groups.settled = visible.map((key) => ({ kind: "thread", key, section: "settled" }));
+    const projected: SidebarListItem[] = [];
+    const marker = (name: SidebarListMarker) => projected.push({ kind: "marker", marker: name });
+    const section = (name: "active" | "settled") => {
+      if (groups[name].length > 0) projected.push(...groups[name]);
+      else marker(`${name}-placeholder`);
+    };
+    marker("pinned-header");
+    projected.push(...groups.pinned);
+    marker("pinned-divider");
+    section("active");
+    if (
+      groups.snoozed.length > 0 ||
+      ((active.section !== "snoozed" || (input.snoozedThreadCount ?? 0) > 1) &&
+        items.some((item) => item.kind === "marker" && item.marker === "snoozed-header"))
+    ) {
+      marker("snoozed-header");
+      projected.push(...groups.snoozed);
+    }
+    marker("settled-header");
+    section("settled");
+    const result = items.map(() => hidden);
+    let top = rects[0].top;
+    for (const item of projected) {
+      const index = indices.get(sidebarListItemId(item));
+      const rect = index === undefined ? undefined : rects[index];
+      if (index !== undefined && rect) result[index] = { ...stationary, y: top - rect.top };
+      const fallback =
+        item.kind === "thread" && (item.section === "pinned" || item.section === "active")
+          ? cardHeight
+          : slimHeight;
+      const moved = item.kind === "thread" && item.key === active.key;
+      top += (moved ? fallback : (rect?.height ?? fallback)) + 1;
+    }
+    result[activeIndex] = stationary;
+    return result;
+  }
+
+  return (args) => {
+    if (
+      previous?.rects !== args.rects ||
+      previous.activeIndex !== args.activeIndex ||
+      previous.overIndex !== args.overIndex
+    ) {
+      previous = args;
+      transforms = project(args);
+    }
+    return transforms === null
+      ? verticalListSortingStrategy(args)
+      : (transforms[args.index] ?? stationary);
+  };
+}
```

## apps/web/src/components/Sidebar.logic.test.ts

Status: modified; +722; −4

```diff
@@ -3,7 +3,8 @@
 import * as Cause from "effect/Cause";
 import { AsyncResult } from "effect/unstable/reactivity";
 import {
+  animateSidebarLayoutChanges,
+  applySidebarThreadDrop,
-  animatePinnedLayoutChanges,
   archiveSelectedThreadEntries,
   buildBulkTitleRegenerationContextMenuItem,
   buildBulkUnpinContextMenuItem,
@@ -32,14 +33,21 @@
   shouldRecedeSidebarThread,
   sortLogicalProjectsForSidebar,
   sortSettledThreadsForSidebar,
+  resolveSidebarDropTarget,
   pinOrderKeyBetween,
   planPinnedReorder,
+  planSidebarThreadDrop,
+  sidebarMarkerId,
+  sidebarListItemId,
   sortPinnedThreadsForSidebar,
   sortThreadsForSidebar,
   sortProjectsForSidebar,
   sortScopedProjectsForSidebar,
   shouldCreateNewThreadInCurrentProject,
   THREAD_JUMP_HINT_SHOW_DELAY_MS,
+  type SidebarListItem,
+  type SidebarListMarker,
+  type SidebarSection,
 } from "./Sidebar.logic";
 import {
   EnvironmentId,
@@ -53,12 +61,13 @@
   DEFAULT_INTERACTION_MODE,
   DEFAULT_RUNTIME_MODE,
   type Project,
+  type SidebarThreadSummary,
   type Thread,
 } from "../types";
 
 const localEnvironmentId = EnvironmentId.make("environment-local");
 
+describe("animateSidebarLayoutChanges", () => {
-describe("animatePinnedLayoutChanges", () => {
   const baseArgs: Parameters<AnimateLayoutChanges>[0] = {
     active: null,
     containerId: "pinned-threads",
@@ -76,11 +85,11 @@
 
   it("does not replay layout movement after the pointer is released", () => {
     expect(defaultAnimateLayoutChanges(baseArgs)).toBe(true);
+    expect(animateSidebarLayoutChanges(baseArgs)).toBe(false);
-    expect(animatePinnedLayoutChanges(baseArgs)).toBe(false);
   });
 
   it("keeps layout movement while the user is sorting", () => {
-    expect(animatePinnedLayoutChanges({ ...baseArgs, isSorting: true })).toBe(true);
+    expect(animateSidebarLayoutChanges({ ...baseArgs, isSorting: true })).toBe(true);
   });
 });
 
@@ -1025,6 +1034,715 @@
   });
 });
 
+describe("resolveSidebarDropTarget", () => {
+  const thread = (key: string, section: SidebarSection): SidebarListItem => ({
+    kind: "thread",
+    key,
+    section,
+  });
+  const marker = (marker: SidebarListMarker): SidebarListItem => ({ kind: "marker", marker });
+  // Pinned p1 p2 | Active a1 a2 | Snoozed z1 | Settled s1
+  const items: readonly SidebarListItem[] = [
+    marker("pinned-header"),
+    thread("p1", "pinned"),
+    thread("p2", "pinned"),
+    marker("pinned-divider"),
+    thread("a1", "active"),
+    thread("a2", "active"),
+    marker("snoozed-header"),
+    thread("z1", "snoozed"),
+    marker("settled-header"),
+    thread("s1", "settled"),
+  ];
+  const resolve = (activeKey: string, overId: string) =>
+    resolveSidebarDropTarget(items, activeKey, overId);
+
+  it("keeps marker-like scoped thread keys draggable", () => {
+    const key = "marker:pinned-header";
+    const list: SidebarListItem[] = [
+      marker("pinned-header"),
+      thread(key, "pinned"),
+      thread("env:other", "pinned"),
+      marker("pinned-divider"),
+    ];
+    expect(new Set(list.map(sidebarListItemId)).size).toBe(list.length);
+    expect(resolveSidebarDropTarget(list, key, "env:other")).toEqual({
+      section: "pinned",
+      pinnedOrder: ["env:other", key],
+      activeOrder: [],
+    });
+  });
+
+  it("reads the section off the markers above the gap", () => {
+    expect(resolve("p1", "a2")).toEqual({
+      section: "active",
+      pinnedOrder: ["p2"],
+      activeOrder: ["a1", "a2", "p1"],
+    });
+    expect(resolve("a1", "s1")).toEqual({
+      section: "settled",
+      pinnedOrder: ["p1", "p2"],
+      activeOrder: ["a2"],
+    });
+    expect(resolve("s1", "a1")).toEqual({
+      section: "active",
+      pinnedOrder: ["p1", "p2"],
+      activeOrder: ["s1", "a1", "a2"],
+    });
+  });
+
+  it("uses arrayMove placement, so a marker hovered from below lands above it", () => {
+    // Dragging a1 up onto the divider: the divider shifts down, a1 becomes
+    // the last pinned row.
+    expect(resolve("a1", sidebarMarkerId("pinned-divider"))).toEqual({
+      section: "pinned",
+      pinnedOrder: ["p1", "p2", "a1"],
+      activeOrder: ["a2"],
+    });
+    // Dragging p2 down onto the divider: the divider shifts up, p2 is the
+    // first inbox row — an unpin.
+    expect(resolve("p2", sidebarMarkerId("pinned-divider"))).toEqual({
+      section: "active",
+      pinnedOrder: ["p1"],
+      activeOrder: ["p2", "a1", "a2"],
+    });
+    // Same on the Settled header: from above it settles; from below the
+    // gap lands in whatever is above the header — here the snoozed shelf,
+    // which is never a target.
+    expect(resolve("a2", sidebarMarkerId("settled-header"))?.section).toBe("settled");
+    expect(resolve("s1", sidebarMarkerId("settled-header"))).toBeNull();
+  });
+
+  it("reorders inside the pinned block with the dragged row at the over slot", () => {
+    expect(resolve("p1", "p2")).toEqual({
+      section: "pinned",
+      pinnedOrder: ["p2", "p1"],
+      activeOrder: ["a1", "a2"],
+    });
+    expect(resolve("a2", "p1")).toEqual({
+      section: "pinned",
+      pinnedOrder: ["a2", "p1", "p2"],
+      activeOrder: ["a1"],
+    });
+  });
+
+  it("lands first in Pinned when hovering its permanent header", () => {
+    expect(resolve("a2", sidebarMarkerId("pinned-header"))).toEqual({
+      section: "pinned",
+      pinnedOrder: ["a2", "p1", "p2"],
+      activeOrder: ["a1"],
+    });
+  });
+
+  it("reorders active rows in either direction without changing sections", () => {
+    for (const [from, to] of [
+      ["a1", "a2"],
+      ["a2", "a1"],
+    ] as const) {
+      expect(resolve(from, to)).toEqual({
+        section: "active",
+        pinnedOrder: ["p1", "p2"],
+        activeOrder: ["a2", "a1"],
+      });
+    }
+  });
+
+  it("never lands in the snoozed shelf", () => {
+    expect(resolve("a1", "z1")).toBeNull();
+    expect(resolve("a1", sidebarMarkerId("snoozed-header"))).toBeNull();
+  });
+
+  it("lands on a placeholder when the section is otherwise empty", () => {
+    const withPlaceholder: readonly SidebarListItem[] = [
+      marker("pinned-header"),
+      marker("pinned-divider"),
+      thread("a1", "active"),
+      marker("settled-header"),
+      marker("settled-placeholder"),
+    ];
+    expect(
+      resolveSidebarDropTarget(withPlaceholder, "a1", sidebarMarkerId("settled-placeholder")),
+    ).toEqual({ section: "settled", pinnedOrder: [], activeOrder: [] });
+  });
+
+  it("lands in empty Pinned using its header without an extra placeholder", () => {
+    const emptyPinned: readonly SidebarListItem[] = [
+      marker("pinned-header"),
+      marker("pinned-divider"),
+      thread("a1", "active"),
+    ];
+    expect(resolveSidebarDropTarget(emptyPinned, "a1", sidebarMarkerId("pinned-header"))).toEqual({
+      section: "pinned",
+      pinnedOrder: ["a1"],
+      activeOrder: [],
+    });
+    expect(resolveSidebarDropTarget(emptyPinned, "a1", sidebarMarkerId("pinned-divider"))).toEqual({
+      section: "pinned",
+      pinnedOrder: ["a1"],
+      activeOrder: [],
+    });
+  });
+
+  it("rejects ids that are not in the list", () => {
+    expect(resolve("a1", "nope")).toBeNull();
+    expect(resolve("nope", "a1")).toBeNull();
+    expect(resolve(sidebarMarkerId("pinned-divider"), "a1")).toBeNull();
+  });
+});
+
+describe("planSidebarThreadDrop", () => {
+  const pinnedKeysById = new Map<string, string | null>([
+    ["p1", "f"],
+    ["p2", "m"],
+    ["p3", "t"],
+  ]);
+  const activeKeysById = new Map<string, string | null>([
+    ["a1", "f"],
+    ["a2", "m"],
+    ["a3", "t"],
+  ]);
+  const plan = (
+    overrides: Partial<Omit<Parameters<typeof planSidebarThreadDrop>[0], "target">> & {
+      activeKey: string;
+      activeSection: "pinned" | "active" | "snoozed" | "settled";
+      target: Omit<Parameters<typeof planSidebarThreadDrop>[0]["target"], "activeOrder"> & {
+        activeOrder?: readonly string[];
+      };
+    },
+  ) =>
+    planSidebarThreadDrop({
+      pinnedOrder: ["p1", "p2", "p3"],
+      pinnedKeysById,
+      activeOrder: ["a1", "a2", "a3"],
+      activeKeysById,
+      ...overrides,
+      target: { activeOrder: [], ...overrides.target },
+    });
+
+  it("allows old-server pinned reordering while rejecting settlement", () => {
+    expect(
+      plan({
+        activeKey: "p1",
+        activeSection: "pinned",
+        supportsSettlement: false,
+        target: { section: "pinned", pinnedOrder: ["p2", "p1", "p3"] },
+      }).kind,
+    ).toBe("reorder-pinned");
+    expect(
+      plan({
+        activeKey: "p1",
+        activeSection: "pinned",
+        supportsSettlement: false,
+        target: { section: "settled", pinnedOrder: ["p2", "p3"] },
+      }),
+    ).toEqual({ kind: "none" });
+  });
+
+  it.each(["pinned", "active"] as const)("reserves hidden %s slots during a drop", (section) => {
+    const order = section === "pinned" ? ["p2", "p1", "p3"] : ["a2", "a1", "a3"];
+    const keys = new Map(section === "pinned" ? pinnedKeysById : activeKeysById);
+    const moved = section === "pinned" ? "p1" : "a1";
+    const reserved = pinOrderKeyBetween(keys.get(order[0]!)!, keys.get(order[2]!)!)!;
+    keys.set("snoozed", reserved);
+    const result = plan({
+      activeKey: moved,
+      activeSection: section,
+      pinnedKeysById: section === "pinned" ? keys : pinnedKeysById,
+      activeKeysById: section === "active" ? keys : activeKeysById,
+      target: {
+        section,
+        pinnedOrder: section === "pinned" ? order : [],
+        activeOrder: section === "active" ? order : [],
+      },
+    });
+    if (result.kind !== "reorder-pinned" && result.kind !== "move-active")
+      throw new Error("Expected reorder");
+    expect(result.assignments).toHaveLength(1);
+    expect(result.assignments[0]!.orderKey).not.toBe(reserved);
+  });
+
+  it.each([
+    { key: "p2", section: "pinned" as const, unpin: true, unsettle: false, unsnooze: false },
+    { key: "s1", section: "settled" as const, unpin: false, unsettle: true, unsnooze: false },
+    { key: "z1", section: "snoozed" as const, unpin: false, unsettle: false, unsnooze: true },
+  ])("moves a $section thread to the chosen Active slot", (source) => {
+    const order = ["a1", source.key, "a2", "a3"];
+    const result = plan({
+      activeKey: source.key,
+      activeSection: source.section,
+      target: { section: "active", pinnedOrder: [], activeOrder: order },
+    });
+    expect(result).toEqual({
+      kind: "move-active",
+      order,
+      assignments: [{ id: source.key, orderKey: expect.any(String) }],
+      unpin: source.unpin,
+      unsettle: source.unsettle,
+      unsnooze: source.unsnooze,
+    });
+    if (result.kind !== "move-active") return;
+    const key = result.assignments[0]!.orderKey;
+    expect(key > "f" && key < "m").toBe(true);
+  });
+
+  it.each([
+    { state: "pinned", activePinned: true, activeSettled: false },
+    { state: "settled", activePinned: false, activeSettled: true },
+    { state: "pinned and settled", activePinned: true, activeSettled: true },
+  ])("clears a snoozed thread's $state state before waking it into Active", (hiddenState) => {
+    expect(
+      plan({
+        activeKey: "z1",
+        activeSection: "snoozed",
+        activePinned: hiddenState.activePinned,
+        activeSettled: hiddenState.activeSettled,
+        target: {
+          section: "active",
+          pinnedOrder: ["p1", "p2", "p3"],
+          activeOrder: ["a1", "z1", "a2", "a3"],
+        },
+      }),
+    ).toEqual({
+      kind: "move-active",
+      order: ["a1", "z1", "a2", "a3"],
+      assignments: [{ id: "z1", orderKey: expect.any(String) }],
+      unpin: hiddenState.activePinned,
+      unsettle: hiddenState.activeSettled,
+      unsnooze: true,
+    });
+  });
+
+  it("saves the first Active reorder, then moves only one key on subsequent drops", () => {
+    const rows = ["a1", "a2", "a3"].map((id, index) => ({
+      id,
+      createdAt: new Date(Date.UTC(2026, 8, 4, 12 - index)).toISOString(),
+      activeOrderKey: null as string | null,
+    }));
+    const firstOrder = ["a2", "a3", "a1"];
+    const first = plan({
+      activeKey: "a1",
+      activeSection: "active",
+      target: { section: "active", pinnedOrder: [], activeOrder: firstOrder },
+      activeKeysById: new Map(rows.map((row) => [row.id, row.activeOrderKey])),
+    });
+    expect(first.kind).toBe("move-active");
+    if (first.kind !== "move-active") return;
+    expect(first.unpin || first.unsettle || first.unsnooze).toBe(false);
+    const savedKeys = new Map(first.assignments.map(({ id, orderKey }) => [id, orderKey]));
+    const savedRows = rows.map((row) => ({
+      ...row,
+      activeOrderKey: savedKeys.get(row.id) ?? null,
+    }));
+    expect(sortThreadsForSidebar(savedRows).map((row) => row.id)).toEqual(firstOrder);
+
+    const secondOrder = ["a2", "a1", "a3"];
+    const second = plan({
+      activeKey: "a1",
+      activeSection: "active",
+      activeOrder: firstOrder,
+      activeKeysById: savedKeys,
+      target: { section: "active", pinnedOrder: [], activeOrder: secondOrder },
+    });
+    expect(second.kind).toBe("move-active");
+    if (second.kind !== "move-active") return;
+    expect(second.assignments).toEqual([{ id: "a1", orderKey: expect.any(String) }]);
+    const finalRows = savedRows.map((row) =>
+      row.id === "a1" ? { ...row, activeOrderKey: second.assignments[0]!.orderKey } : row,
+    );
+    expect(sortThreadsForSidebar(finalRows).map((row) => row.id)).toEqual(secondOrder);
+  });
+
+  it("does not write when an Active thread is dropped in its existing slot", () => {
+    expect(
+      plan({
+        activeKey: "a2",
+        activeSection: "active",
+        target: { section: "active", pinnedOrder: [], activeOrder: ["a1", "a2", "a3"] },
+      }),
+    ).toEqual({ kind: "none" });
+  });
+
+  it("requires Active ordering support only for the threads whose keys must change", () => {
+    const input = {
+      activeKey: "a3",
+      activeSection: "active" as const,
+      target: { section: "active" as const, pinnedOrder: [], activeOrder: ["a1", "a3", "a2"] },
+      activeReorderableKeys: new Set(["a3"]),
+    };
+    expect(plan(input).kind).toBe("move-active");
+    expect(
+      plan({
+        ...input,
+        activeKeysById: new Map([
+          ["a1", null],
+          ["a2", "m"],
+          ["a3", "t"],
+        ]),
+      }),
+    ).toEqual({ kind: "none" });
+    expect(plan({ ...input, activeReorderableKeys: new Set() })).toEqual({ kind: "none" });
+  });
+
+  it("settles anything dropped on Settled except a settled thread", () => {
+    const target = { section: "settled", pinnedOrder: ["p1", "p2", "p3"] } as const;
+    expect(plan({ activeKey: "a1", activeSection: "active", target })).toEqual({ kind: "settle" });
+    expect(plan({ activeKey: "p1", activeSection: "pinned", target })).toEqual({ kind: "settle" });
+    expect(plan({ activeKey: "z1", activeSection: "snoozed", target })).toEqual({ kind: "settle" });
+    expect(plan({ activeKey: "s1", activeSection: "settled", target })).toEqual({ kind: "none" });
+  });
+
+  it("pins a foreign thread with a key between its new neighbors", () => {
+    const result = plan({
+      activeKey: "a1",
+      activeSection: "active",
+      target: { section: "pinned", pinnedOrder: ["p1", "a1", "p2", "p3"] },
+    });
+    expect(result.kind).toBe("pin");
+    if (result.kind !== "pin") return;
+    expect(result.order).toEqual(["p1", "a1", "p2", "p3"]);
+    expect(result.orderKey).toBeDefined();
+    expect(result.orderKey! > "f" && result.orderKey! < "m").toBe(true);
+    expect(result.extraAssignments).toEqual([]);
+
+    const empty = plan({
+      activeKey: "a1",
+      activeSection: "active",
+      target: { section: "pinned", pinnedOrder: ["a1"] },
+      pinnedOrder: [],
+      pinnedKeysById: new Map(),
+    });
+    expect(empty.kind).toBe("pin");
+    if (empty.kind !== "pin") return;
+    expect(empty.orderKey).toBeDefined();
+  });
+
+  it("reorders an already-pinned snoozed thread after pinning wakes it", () => {
+    const result = plan({
+      activeKey: "z1",
+      activeSection: "snoozed",
+      activePinned: true,
+      target: { section: "pinned", pinnedOrder: ["p1", "z1", "p2", "p3"] },
+      pinnedKeysById: new Map([...pinnedKeysById, ["z1", "x"]]),
+    });
+    expect(result.kind).toBe("pin");
+    if (result.kind !== "pin") return;
+    expect(result.extraAssignments).toEqual([{ id: "z1", orderKey: result.orderKey }]);
+    expect(result.orderKey! > "f" && result.orderKey! < "m").toBe(true);
+  });
+
+  it("uses keyed disabled neighbors as anchors without writing to them", () => {
+    const insertion = plan({
+      activeKey: "a1",
+      activeSection: "active",
+      target: { section: "pinned", pinnedOrder: ["p1", "a1", "p2", "p3"] },
+      reorderableKeys: new Set(["a1"]),
+    });
+    expect(insertion.kind).toBe("pin");
+    if (insertion.kind !== "pin") return;
+    expect(insertion.order).toEqual(["p1", "a1", "p2", "p3"]);
+    expect(insertion.orderKey! > "f" && insertion.orderKey! < "m").toBe(true);
+    expect(insertion.extraAssignments).toEqual([]);
+
+    const reorder = plan({
+      activeKey: "p3",
+      activeSection: "pinned",
+      target: { section: "pinned", pinnedOrder: ["p1", "p3", "p2"] },
+      reorderableKeys: new Set(["p3"]),
+    });
+    expect(reorder.kind).toBe("reorder-pinned");
+    if (reorder.kind !== "reorder-pinned") return;
+    expect(reorder.assignments).toEqual([{ id: "p3", orderKey: expect.any(String) }]);
+    expect(reorder.assignments[0]!.orderKey > "f").toBe(true);
+    expect(reorder.assignments[0]!.orderKey < "m").toBe(true);
+  });
+
+  it.each([
+    {
+      activeKey: "a1",
+      activeSection: "active" as const,
+      order: ["p1", "p3", "a1", "p2"],
+    },
+    { activeKey: "p1", activeSection: "pinned" as const, order: ["p3", "p1", "p2"] },
+  ])("rejects $activeSection drops that require rewriting a disabled neighbor", (source) => {
+    expect(
+      plan({
+        activeKey: source.activeKey,
+        activeSection: source.activeSection,
+        target: { section: "pinned", pinnedOrder: source.order },
+        pinnedOrder: ["p1", "p3", "p2"],
+        pinnedKeysById: new Map([
+          ["p1", "f"],
+          ["p2", null],
+          ["p3", "t"],
+        ]),
+        reorderableKeys: new Set(["p1", "p3", source.activeKey]),
+      }),
+    ).toEqual({ kind: "none" });
+  });
+
+  it("rewrites the section when a foreign thread lands next to a keyless pin", () => {
+    const result = plan({
+      activeKey: "a1",
+      activeSection: "active",
+      target: { section: "pinned", pinnedOrder: ["p1", "a1", "p2", "p3"] },
+      pinnedKeysById: new Map([
+        ["p1", null],
+        ["p2", "m"],
+        ["p3", "t"],
+      ]),
+    });
+    expect(result.kind).toBe("pin");
+    if (result.kind !== "pin") return;
+    expect(result.orderKey).toBeDefined();
+    expect(result.extraAssignments.map((entry) => entry.id)).toEqual(["p1", "p2", "p3"]);
+    const byId = new Map([
+      ["a1", result.orderKey!],
+      ...result.extraAssignments.map((e) => [e.id, e.orderKey] as const),
+    ]);
+    const ordered = result.order.map((id) => byId.get(id)!);
+    expect([...ordered].sort()).toEqual(ordered);
+  });
+
+  it("reorders within the pinned block, and is a no-op when the order is unchanged", () => {
+    const down = plan({
+      activeKey: "p1",
+      activeSection: "pinned",
+      target: { section: "pinned", pinnedOrder: ["p2", "p3", "p1"] },
+    });
+    expect(down.kind).toBe("reorder-pinned");
+    if (down.kind !== "reorder-pinned") return;
+    expect(down.assignments).toEqual([{ id: "p1", orderKey: expect.any(String) }]);
+    expect(down.assignments[0]!.orderKey > "t").toBe(true);
+
+    expect(
+      plan({
+        activeKey: "p1",
+        activeSection: "pinned",
+        target: { section: "pinned", pinnedOrder: ["p1", "p2", "p3"] },
+      }),
+    ).toEqual({ kind: "none" });
+  });
+});
+
+describe("applySidebarThreadDrop", () => {
+  const createdAt = "2026-03-09T08:00:00.000Z";
+  const earlier = "2026-03-09T09:00:00.000Z";
+  const now = "2026-03-09T12:00:00.000Z";
+  const serverNow = "2026-03-09T12:00:01.000Z";
+  const wakeAt = "2026-03-10T08:00:00.000Z";
+  const thread = (overrides: Partial<SidebarThreadSummary> = {}) => ({
+    id: ThreadId.make("dragged"),
+    title: "Keep this title",
+    createdAt,
+    updatedAt: earlier,
+    latestUserMessageAt: null,
+    latestTurn: null,
+    pinnedAt: null,
+    pinOrderKey: null,
+    activeOrderKey: null,
+    snoozedAt: null,
+    snoozedUntil: null,
+    settledAt: null,
+    settledOverride: null,
+    unsettledAt: null,
+    ...overrides,
+  });
+  const newer = thread({ id: ThreadId.make("newer"), createdAt: "2026-03-09T11:00:00.000Z" });
+
+  it("previews an un-settle at the same active position as the eventual server row", () => {
+    const source = thread({ settledOverride: "settled", settledAt: earlier });
+    const preview = applySidebarThreadDrop(source, "active", now);
+    const final = {
+      ...source,
+      settledOverride: "active" as const,
+      settledAt: null,
+      unsettledAt: serverNow,
+    };
+    expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual([
+      "dragged",
+      "newer",
+    ]);
+    expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual(
+      sortThreadsForSidebar([newer, final]).map((row) => row.id),
+    );
+  });
+
+  it.each([
+    { state: "pin", pinnedAt: earlier, pinOrderKey: "m", snoozedAt: null, snoozedUntil: null },
+    {
+      state: "snooze",
+      pinnedAt: null,
+      pinOrderKey: null,
+      snoozedAt: earlier,
+      snoozedUntil: wakeAt,
+    },
+    {
+      state: "snoozed pin",
+      pinnedAt: earlier,
+      pinOrderKey: "m",
+      snoozedAt: earlier,
+      snoozedUntil: wakeAt,
+    },
+  ])("preserves the active sort anchor when clearing a $state", ({ state: _state, ...parked }) => {
+    const source = thread({ ...parked, settledOverride: "active", unsettledAt: earlier });
+    const preview = applySidebarThreadDrop(source, "active", now);
+    const final = {
+      ...source,
+      pinnedAt: null,
+      pinOrderKey: null,
+      snoozedAt: null,
+      snoozedUntil: null,
+      updatedAt: serverNow,
+    };
+    expect(preview).toEqual({ ...final, updatedAt: source.updatedAt });
+    expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual([
+      "newer",
+      "dragged",
+    ]);
+    expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual(
+      sortThreadsForSidebar([newer, final]).map((row) => row.id),
+    );
+  });
+
+  it("clears underlying pinning and settlement when waking into Active", () => {
+    const source = thread({
+      pinnedAt: earlier,
+      pinOrderKey: "m",
+      snoozedAt: earlier,
+      snoozedUntil: wakeAt,
+      settledOverride: "settled",
+      settledAt: earlier,
+    });
+    expect(applySidebarThreadDrop(source, "active", now)).toEqual({
+      ...source,
+      pinnedAt: null,
+      pinOrderKey: null,
+      snoozedAt: null,
+      snoozedUntil: null,
+      settledOverride: "active",
+      settledAt: null,
+      unsettledAt: now,
+    });
+  });
+
+  it("previews a new settlement at the same position as the eventual server row", () => {
+    const source = thread({
+      pinnedAt: earlier,
+      pinOrderKey: "m",
+      snoozedAt: earlier,
+      snoozedUntil: wakeAt,
+      unsettledAt: earlier,
+    });
+    const preview = applySidebarThreadDrop(source, "settled", now);
+    const final = {
+      ...source,
+      pinnedAt: null,
+      pinOrderKey: null,
+      snoozedAt: null,
+      snoozedUntil: null,
+      settledOverride: "settled" as const,
+      settledAt: serverNow,
+      unsettledAt: null,
+    };
+    const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt };
+    expect(preview).toEqual({ ...final, settledAt: now });
+    expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([
+      "dragged",
+      "newer",
+    ]);
+    expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual(
+      sortSettledThreadsForSidebar([existing, final]).map((row) => row.id),
+    );
+  });
+
+  it("retains a snoozed thread's earlier settlement and its position when settling again", () => {
+    const source = thread({
+      snoozedAt: earlier,
+      snoozedUntil: wakeAt,
+      settledOverride: "settled",
+      settledAt: earlier,
+    });
+    const preview = applySidebarThreadDrop(source, "settled", now);
+    const final = { ...source, snoozedAt: null, snoozedUntil: null };
+    const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt };
+    expect(preview).toEqual(final);
+    expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([
+      "newer",
+      "dragged",
+    ]);
+  });
+
+  it("pins a settled thread at its requested slot and projects the re-entry stamp", () => {
+    const source = thread({
+      snoozedAt: earlier,
+      snoozedUntil: wakeAt,
+      settledOverride: "settled",
+      settledAt: earlier,
+    });
+    const original = { ...source };
+    const preview = applySidebarThreadDrop(source, "pinned", now, "m");
+    expect(preview).toEqual({
+      ...source,
+      pinnedAt: now,
+      pinOrderKey: "m",
+      snoozedAt: null,
+      snoozedUntil: null,
+      settledOverride: "active",
+      settledAt: null,
+      unsettledAt: now,
+    });
+    expect(
+      sortPinnedThreadsForSidebar([
+        thread({ id: ThreadId.make("after"), pinnedAt: earlier, pinOrderKey: "t" }),
+        preview,
+        thread({ id: ThreadId.make("before"), pinnedAt: earlier, pinOrderKey: "f" }),
+      ]).map((row) => row.id),
+    ).toEqual(["before", "dragged", "after"]);
+    expect(source).toEqual(original);
+  });
+
+  it("keeps an existing pin's timestamp and key unless the drop supplies a new key", () => {
+    const source = thread({
+      pinnedAt: earlier,
+      pinOrderKey: "t",
+      snoozedAt: earlier,
+      snoozedUntil: wakeAt,
+      settledOverride: "active",
+      unsettledAt: earlier,
+    });
+    const unchangedSlot = applySidebarThreadDrop(source, "pinned", now);
+    expect(unchangedSlot).toEqual({ ...source, snoozedAt: null, snoozedUntil: null });
+    expect(applySidebarThreadDrop(source, "pinned", now, "m")).toEqual({
+      ...unchangedSlot,
+      pinOrderKey: "m",
+    });
+  });
+
+  it("keeps an Active drop at its chosen position after unpinning", () => {
+    const source = thread({ pinnedAt: earlier, pinOrderKey: "g", activeOrderKey: "z" });
+    const preview = applySidebarThreadDrop(source, "active", now, "m");
+    expect(preview).toMatchObject({ pinnedAt: null, pinOrderKey: null, activeOrderKey: "m" });
+    expect(
+      sortThreadsForSidebar([
+        thread({ id: ThreadId.make("after"), activeOrderKey: "t" }),
+        preview,
+        thread({ id: ThreadId.make("before"), activeOrderKey: "f" }),
+      ]).map((row) => row.id),
+    ).toEqual(["before", "dragged", "after"]);
+  });
+
+  it("clears the manual Active position when settling so reopening returns to the top", () => {
+    const source = thread({ activeOrderKey: "z" });
+    const settled = applySidebarThreadDrop(source, "settled", now);
+    expect(settled.activeOrderKey).toBeNull();
+    const reopened = applySidebarThreadDrop(settled, "active", serverNow);
+    expect(sortThreadsForSidebar([newer, reopened]).map((row) => row.id)).toEqual([
+      "dragged",
+      "newer",
+    ]);
+  });
+});
+
 describe("sortPinnedThreadsForSidebar", () => {
   const pinnable = (input: { id: string; createdAt: string; pinOrderKey?: string | null }) => ({
     id: input.id,
```

## apps/web/src/components/Sidebar.logic.ts

Status: modified; +251; −25

```diff
@@ -7,8 +7,8 @@
 import type { ContextMenuItem } from "@t3tools/contracts";
 import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
 import type { AsyncResult } from "effect/unstable/reactivity";
+import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";
 import {
-  activeThreadAnchorTimestampMs,
   getThreadSortTimestamp,
   resolveSettledThreadTimestamp,
   sortThreads,
@@ -81,12 +81,257 @@
   return key !== null && retained.current?.key === key ? retained.current.value : null;
 }
 
+// Sidebar.motion handles ordinary section changes. Sortable transforms own
+// dragging; replaying their committed DOM order would animate the drop twice.
+export const animateSidebarLayoutChanges: AnimateLayoutChanges = (args) =>
-// The list already reaches its destination through sortable transforms while
-// the pointer is down. dnd-kit's default also animates the committed DOM order
-// after release, replaying the same movement across every affected row.
-export const animatePinnedLayoutChanges: AnimateLayoutChanges = (args) =>
   args.isSorting ? defaultAnimateLayoutChanges(args) : false;
 
+// Rows and section markers share one sortable list. The separators resolve
+// the lifecycle action; Sidebar.drag previews the resulting layout. Pinned
+// and active threads keep the dragged position; settled threads use time
+// order. Snoozed rows can leave the shelf, but dropping into it is not
+// supported because snoozing requires a wake time.
+
+export type SidebarSection = "pinned" | "active" | "snoozed" | "settled";
+
+/** Sortable ids: thread rows use their scoped key; structural items use a
+    colon-free prefix: scoped thread keys always contain a colon. */
+const SIDEBAR_MARKER_PREFIX = "sidebar-marker-";
+
+export type SidebarListMarker =
+  /** The top boundary is also a landing target when there are no pins. */
+  | "pinned-header"
+  /** Stand-in rows so an empty section has somewhere for the gap to open. */
+  | "active-placeholder"
+  | "settled-placeholder"
+  /** The boundary between pinned and active rows. */
+  | "pinned-divider"
+  | "snoozed-header"
+  | "settled-header";
+
+export function sidebarMarkerId(marker: SidebarListMarker): string {
+  return `${SIDEBAR_MARKER_PREFIX}${marker}`;
+}
+
+export type SidebarListItem =
+  | { readonly kind: "thread"; readonly key: string; readonly section: SidebarSection }
+  | { readonly kind: "marker"; readonly marker: SidebarListMarker };
+
+export function sidebarListItemId(item: SidebarListItem): string {
+  return item.kind === "thread" ? item.key : sidebarMarkerId(item.marker);
+}
+
+/** The section a slot belongs to, read off the markers around it: from
+    the top down, everything before the pinned divider is pinned, then the
+    inbox until the snoozed header, the shelf until the settled header,
+    then settled. */
+function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): SidebarSection {
+  let section: SidebarSection = "pinned";
+  for (let i = 0; i < index && i < items.length; i += 1) {
+    const item = items[i]!;
+    if (item.kind !== "marker") continue;
+    if (item.marker === "pinned-divider") section = "active";
+    else if (item.marker === "snoozed-header") section = "snoozed";
+    else if (item.marker === "settled-header") section = "settled";
+  }
+  return section;
+}
+
+/** Resolve the destination section and manual order from an arrayMove across
+ * the separators. The snoozed shelf is never a destination. */
+export type SidebarDropTarget = {
+  readonly section: "pinned" | "active" | "settled";
+  readonly pinnedOrder: readonly string[];
+  readonly activeOrder: readonly string[];
+};
+
+export function resolveSidebarDropTarget(
+  items: readonly SidebarListItem[],
+  activeKey: string,
+  overId: string,
+): SidebarDropTarget | null {
+  const activeIndex = items.findIndex((item) => sidebarListItemId(item) === activeKey);
+  const overIndex = items.findIndex((item) => sidebarListItemId(item) === overId);
+  if (activeIndex === -1 || overIndex === -1 || items[activeIndex]?.kind !== "thread") return null;
+  const moved = items.filter((_, index) => index !== activeIndex);
+  moved.splice(overIndex, 0, items[activeIndex]!);
+  const section = sectionAtSidebarSlot(moved, overIndex);
+  if (section === "snoozed") return null;
+  const pinnedOrder: string[] = [];
+  const activeOrder: string[] = [];
+  let currentSection: SidebarSection = "pinned";
+  for (const item of moved) {
+    if (item.kind === "marker") {
+      if (item.marker === "pinned-divider") currentSection = "active";
+      else if (item.marker === "snoozed-header" || item.marker === "settled-header") break;
+    } else if (currentSection === "pinned") pinnedOrder.push(item.key);
+    else activeOrder.push(item.key);
+  }
+  return { section, pinnedOrder, activeOrder };
+}
+
+export type SidebarThreadDropPlan =
+  | { readonly kind: "none" }
+  /** Within the pinned block: the existing key writes. */
+  | {
+      readonly kind: "reorder-pinned";
+      readonly order: readonly string[];
+      readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;
+    }
+  /** From another section into the pinned block. Fresh pins take `orderKey`
+      on the pin command. `extraAssignments` land afterward, including the
+      moved row when it was already pinned beneath a snooze. */
+  | {
+      readonly kind: "pin";
+      readonly order: readonly string[];
+      readonly orderKey: string | undefined;
+      readonly extraAssignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;
+    }
+  | {
+      readonly kind: "move-active";
+      readonly order: readonly string[];
+      readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;
+      readonly unpin: boolean;
+      readonly unsettle: boolean;
+      readonly unsnooze: boolean;
+    }
+  | { readonly kind: "settle" };
+
+export function planSidebarThreadDrop(input: {
+  readonly activeKey: string;
+  readonly activeSection: SidebarSection;
+  /** Snoozed threads can retain pinning and settlement beneath the shelf. */
+  readonly activePinned?: boolean;
+  readonly activeSettled?: boolean;
+  readonly supportsSettlement?: boolean;
+  readonly target: SidebarDropTarget;
+  /** All pinned keys in displayed order before the drop. */
+  readonly pinnedOrder: readonly string[];
+  readonly pinnedKeysById: ReadonlyMap<string, string | null | undefined>;
+  readonly reorderableKeys?: ReadonlySet<string>;
+  readonly activeOrder: readonly string[];
+  readonly activeKeysById: ReadonlyMap<string, string | null | undefined>;
+  readonly activeReorderableKeys?: ReadonlySet<string>;
+}): SidebarThreadDropPlan {
+  const {
+    activeKey,
+    activeSection,
+    activePinned = activeSection === "pinned",
+    activeSettled = activeSection === "settled",
+    target,
+    pinnedOrder,
+    pinnedKeysById,
+    reorderableKeys,
+    activeOrder,
+    activeKeysById,
+    activeReorderableKeys,
+  } = input;
+  if (input.supportsSettlement === false && (target.section === "settled" || activeSettled)) {
+    return { kind: "none" };
+  }
+  switch (target.section) {
+    case "active": {
+      const order = target.activeOrder;
+      if (
+        activeSection === "active" &&
+        order.length === activeOrder.length &&
+        order.every((key, index) => key === activeOrder[index])
+      ) {
+        return { kind: "none" };
+      }
+      const assignments = planPinnedReorder({
+        orderedIds: order,
+        keysById: activeKeysById,
+        movedId: activeKey,
+      });
+      if (activeReorderableKeys && assignments.some(({ id }) => !activeReorderableKeys.has(id))) {
+        return { kind: "none" };
+      }
+      return {
+        kind: "move-active",
+        order,
+        assignments,
+        unpin: activePinned,
+        unsettle: activeSettled,
+        unsnooze: activeSection === "snoozed",
+      };
+    }
+    case "settled":
+      return activeSection === "settled" ? { kind: "none" } : { kind: "settle" };
+    case "pinned": {
+      const order = target.pinnedOrder;
+      // Dropped back where it started: nothing to write.
+      if (
+        activeSection === "pinned" &&
+        order.length === pinnedOrder.length &&
+        order.every((key, index) => key === pinnedOrder[index])
+      ) {
+        return { kind: "none" };
+      }
+      const assignments = planPinnedReorder({
+        orderedIds: order,
+        keysById: pinnedKeysById,
+        movedId: activeKey,
+      });
+      if (reorderableKeys && assignments.some(({ id }) => !reorderableKeys.has(id))) {
+        return { kind: "none" };
+      }
+      if (activeSection === "pinned") {
+        return assignments.length === 0
+          ? { kind: "none" }
+          : { kind: "reorder-pinned", order, assignments };
+      }
+      return {
+        kind: "pin",
+        order,
+        orderKey: assignments.find((assignment) => assignment.id === activeKey)?.orderKey,
+        extraAssignments: activePinned
+          ? assignments
+          : assignments.filter((assignment) => assignment.id !== activeKey),
+      };
+    }
+  }
+}
+
+/** Project a drop's lifecycle fields before sorting its destination. Reusing
+    the server's re-entry rules keeps the preview in place when events arrive. */
+export function applySidebarThreadDrop<
+  T extends Pick<
+    SidebarThreadSummary,
+    | "pinnedAt"
+    | "pinOrderKey"
+    | "activeOrderKey"
+    | "snoozedAt"
+    | "snoozedUntil"
+    | "settledAt"
+    | "settledOverride"
+    | "unsettledAt"
+  >,
+>(thread: T, section: "pinned" | "active" | "settled", now: string, orderKey?: string): T {
+  const wasSettled = thread.settledOverride === "settled";
+  const awake = { ...thread, snoozedAt: null, snoozedUntil: null };
+  if (section === "settled") {
+    return {
+      ...awake,
+      pinnedAt: null,
+      pinOrderKey: null,
+      activeOrderKey: null,
+      settledOverride: "settled",
+      settledAt: wasSettled ? (thread.settledAt ?? now) : now,
+      unsettledAt: null,
+    };
+  }
+  const resumed = wasSettled
+    ? { ...awake, settledOverride: "active" as const, settledAt: null, unsettledAt: now }
+    : awake;
+  return {
+    ...resumed,
+    pinnedAt: section === "pinned" ? (thread.pinnedAt ?? now) : null,
+    pinOrderKey: section === "pinned" ? (orderKey ?? thread.pinOrderKey) : null,
+    ...(section === "active" && orderKey !== undefined ? { activeOrderKey: orderKey } : {}),
+  };
+}
+
 type SidebarProject = {
   id: string;
   title: string;
@@ -605,26 +850,7 @@
   return null;
 }
 
-// Sidebar sort: static order, newest anchor on top. Activity NEVER reorders
-// the list — a row holds its position between lifecycle transitions, so the
-// screen only moves when a thread enters or leaves the active list. The
-// anchor is creation time until an un-settle re-anchors it (see
-// activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the
+export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";
-// top instead of sinking back to its creation-order slot. Status (including
-// pending approval) is carried by each card's edge strip, not by position.
-export function sortThreadsForSidebar<
-  T extends {
-    readonly id: string;
-    readonly createdAt: string;
-    readonly unsettledAt?: string | null | undefined;
-  },
->(threads: readonly T[]): T[] {
-  return [...threads].toSorted(
-    (left, right) =>
-      activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) ||
-      left.id.localeCompare(right.id),
-  );
-}
 
 // Pinned-reorder key math and the keyed sort live in client-runtime
 // (state/thread-sort) so web and mobile compute identical pinned orders.
```

## apps/web/src/components/Sidebar.motion.test.ts

Status: added; +339; −0

```diff
@@ -1,0 +1,339 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+import { createSidebarListMotion } from "./Sidebar.motion";
+
+class TestAnimation {
+  progress: number | null = 0;
+  playState: AnimationPlayState = "running";
+  effect = { getComputedTiming: () => ({ progress: this.progress }) };
+  cancel = vi.fn(() => {
+    this.playState = "idle";
+  });
+  private onFinish: (() => void) | undefined;
+  addEventListener(_type: string, listener: () => void) {
+    this.onFinish = listener;
+  }
+  finish() {
+    this.playState = "finished";
+    this.onFinish?.();
+  }
+}
+
+class TestRow {
+  offsetTop = 0;
+  offsetLeft = 4;
+  offsetWidth = 260;
+  namespaceURI = "http://www.w3.org/1999/xhtml";
+  dragTranslate = 0;
+  style: Record<string, string> = {};
+  inert = false;
+  attributes: { name: string; value: string }[] = [];
+  children: TestRow[] = [];
+  clones: TestRow[] = [];
+  remove = vi.fn();
+  animations: TestAnimation[] = [];
+  constructor(
+    readonly name: string,
+    public offsetHeight = 82,
+  ) {}
+  getBoundingClientRect() {
+    return { top: this.offsetTop + this.dragTranslate, height: this.offsetHeight };
+  }
+  setAttribute(name: string, value: string) {
+    this.removeAttribute(name);
+    this.attributes.push({ name, value });
+  }
+  removeAttribute(name: string) {
+    this.attributes = this.attributes.filter((attribute) => attribute.name !== name);
+  }
+  querySelectorAll(_selector: string): TestRow[] {
+    return this.children.flatMap((child) => [child, ...child.querySelectorAll("*")]);
+  }
+  cloneNode(_deep: boolean): TestRow {
+    const clone = new TestRow(`${this.name} clone`, this.offsetHeight);
+    clone.namespaceURI = this.namespaceURI;
+    clone.style = { ...this.style };
+    clone.attributes = this.attributes.map((attribute) => ({ ...attribute }));
+    clone.children = this.children.map((child) => child.cloneNode(true));
+    this.clones.push(clone);
+    return clone;
+  }
+  animate = vi.fn((_frames: Keyframe[], _options: KeyframeAnimationOptions) => {
+    const animation = new TestAnimation();
+    this.animations.push(animation);
+    return animation;
+  });
+}
+
+function fixture(rows: TestRow[]) {
+  const media = { matches: false };
+  const parent = {
+    children: rows,
+    ownerDocument: { defaultView: { matchMedia: () => media } },
+    append(node: TestRow) {
+      parent.children.push(node);
+      node.remove.mockImplementation(() => {
+        parent.children = parent.children.filter((child) => child !== node);
+      });
+    },
+  };
+  function layout(next: TestRow[]) {
+    let top = 8;
+    for (const row of next) {
+      row.offsetTop = top;
+      top += row.offsetHeight + 1;
+    }
+    parent.children = [
+      ...next,
+      ...parent.children.filter((row) => row.style.position === "absolute"),
+    ];
+  }
+  layout(rows);
+  const motion = createSidebarListMotion(parent as unknown as HTMLUListElement);
+  return { motion, layout, media, parent };
+}
+
+function expectMove(row: TestRow, offset: number) {
+  expect(row.animate).toHaveBeenLastCalledWith(
+    [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }],
+    { duration: 150, easing: "ease-out" },
+  );
+}
+
+beforeEach(() => vi.stubGlobal("HTMLElement", TestRow));
+afterEach(() => vi.unstubAllGlobals());
+
+describe("sidebar list motion", () => {
+  it("moves a retained Active row into Settled with its displaced peers", () => {
+    const pinnedHeader = new TestRow("Pinned", 0);
+    const pinned = new TestRow("pin");
+    const divider = new TestRow("Active", 0);
+    const a = new TestRow("a");
+    const b = new TestRow("b");
+    const settledHeader = new TestRow("Settled", 32);
+    const settled = new TestRow("settled", 36);
+    const rows = [pinnedHeader, pinned, divider, a, b, settledHeader, settled];
+    const { motion, layout } = fixture(rows);
+    motion.update(true);
+    expect(rows.every((row) => row.animations.length === 0)).toBe(true);
+
+    a.offsetHeight = 36;
+    layout([pinnedHeader, pinned, divider, b, settledHeader, settled, a]);
+    motion.update(true);
+    expectMove(a, -153);
+    expectMove(b, 83);
+    expectMove(settledHeader, 83);
+    expectMove(settled, 83);
+    expect(pinned.animate).not.toHaveBeenCalled();
+    expect(divider.animate).not.toHaveBeenCalled();
+  });
+
+  it("refreshes the drop baseline without replay and animates the next ordinary move", () => {
+    const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")];
+    const { motion, layout } = fixture([a, b, c]);
+    motion.update(true);
+    motion.suspend();
+    a.dragTranslate = 300;
+    b.dragTranslate = -83;
+    motion.update(false);
+    motion.suspend();
+    layout([b, a, c]);
+    a.dragTranslate = b.dragTranslate = 0;
+    motion.update(true);
+    expect([a, b, c].every((row) => row.animations.length === 0)).toBe(true);
+
+    layout([c, b, a]);
+    motion.update(true);
+    expectMove(c, 166);
+    expectMove(b, -83);
+    expectMove(a, -83);
+  });
+
+  it("does not carry a canceled drag's transformed position into the next move", () => {
+    const a = new TestRow("a");
+    const b = new TestRow("b");
+    const { motion, layout } = fixture([a, b]);
+    motion.update(true);
+    motion.suspend();
+    a.dragTranslate = 500;
+    b.dragTranslate = -83;
+    motion.update(false);
+    motion.suspend();
+    a.dragTranslate = b.dragTranslate = 0;
+    motion.update(true);
+    expect(a.animate).not.toHaveBeenCalled();
+    expect(b.animate).not.toHaveBeenCalled();
+
+    layout([b, a]);
+    motion.update(true);
+    expectMove(a, -83);
+    expectMove(b, 83);
+  });
+
+  it("retargets rapid changes from the current visual position", () => {
+    const [a, b, c] = [new TestRow("a", 99), new TestRow("b", 99), new TestRow("c", 99)];
+    const { motion, layout } = fixture([a, b, c]);
+    motion.update(true);
+    layout([b, c, a]);
+    motion.update(true);
+    expectMove(a, -200);
+    const first = a.animations[0]!;
+    first.progress = 0.25;
+
+    layout([b, a, c]);
+    motion.update(true);
+    expect(first.cancel).toHaveBeenCalledOnce();
+    expectMove(a, -50);
+    first.finish();
+    motion.suspend();
+    expect(a.animations[1]!.cancel).toHaveBeenCalledOnce();
+  });
+
+  it("keeps an uninterrupted movement when the layout position does not change", () => {
+    const a = new TestRow("a");
+    const b = new TestRow("b");
+    const { motion, layout } = fixture([a, b]);
+    motion.update(true);
+    layout([b, a]);
+    motion.update(true);
+    a.animations[0]!.progress = 0.5;
+    motion.update(true);
+    expect(a.animate).toHaveBeenCalledOnce();
+    expect(a.animations[0]!.cancel).not.toHaveBeenCalled();
+  });
+
+  it("cancels owned motion on suspension and never animates a disposed list", () => {
+    const a = new TestRow("a");
+    const b = new TestRow("b");
+    const { motion, layout } = fixture([a, b]);
+    motion.update(true);
+    layout([b, a]);
+    motion.update(true);
+    motion.suspend();
+    expect(a.animations[0]!.cancel).toHaveBeenCalledOnce();
+    expect(b.animations[0]!.cancel).toHaveBeenCalledOnce();
+    motion.update(false);
+    layout([a, b]);
+    motion.update(true);
+    motion.dispose();
+    expect(a.animations[1]!.cancel).toHaveBeenCalledOnce();
+    layout([b, a]);
+    motion.update(true);
+    expect(a.animate).toHaveBeenCalledTimes(2);
+  });
+
+  it("fades a collapsed-shelf exit at its current visual box and a new wake in", () => {
+    const a = new TestRow("a");
+    const b = new TestRow("b");
+    const fresh = new TestRow("new");
+    a.setAttribute("data-thread-item", "a");
+    a.children = [new TestRow("button")];
+    a.children[0]!.setAttribute("id", "thread-control");
+    a.children[0]!.setAttribute("data-testid", "thread-control");
+    a.children[0]!.setAttribute("data-state", "open");
+    const icon = new TestRow("provider icon");
+    icon.namespaceURI = "http://www.w3.org/2000/svg";
+    icon.setAttribute("id", "provider-mask");
+    icon.setAttribute("mask", "url(#provider-mask)");
+    a.children.push(icon);
+    const { motion, layout, parent } = fixture([a, b]);
+    motion.update(true);
+    layout([b, a]);
+    motion.update(true);
+    a.animations[0]!.progress = 0.5;
+    layout([b, fresh]);
+    motion.update(true);
+    expect(a.animations[0]!.cancel).toHaveBeenCalledOnce();
+    expect(fresh.animate).toHaveBeenLastCalledWith([{ opacity: 0 }, { opacity: 1 }], {
+      duration: 150,
+      easing: "ease-out",
+    });
+    const clone = a.clones[0]!;
+    expect(clone.style).toMatchObject({
+      position: "absolute",
+      top: "49.5px",
+      left: "4px",
+      width: "260px",
+      height: "82px",
+      transform: "none",
+      pointerEvents: "none",
+    });
+    expect(clone.inert).toBe(true);
+    expect(clone.attributes).toEqual([{ name: "aria-hidden", value: "true" }]);
+    expect(clone.children[0]!.attributes).toEqual([{ name: "data-state", value: "open" }]);
+    expect(clone.children[1]!.attributes).toEqual(icon.attributes);
+    expect(clone.animate).toHaveBeenCalledWith([{ opacity: 1 }, { opacity: 0 }], {
+      duration: 150,
+      easing: "ease-out",
+    });
+    expect(parent.children.includes(clone)).toBe(true);
+    motion.update(true);
+    expect(clone.animations).toHaveLength(1);
+    expect(clone.clones).toHaveLength(0);
+    clone.animations[0]!.finish();
+    expect(parent.children.includes(clone)).toBe(false);
+  });
+
+  it("clears exit clones on pickup and does not fade the release commit", () => {
+    const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")];
+    const { motion, layout, parent } = fixture([a, b]);
+    motion.update(false);
+    layout([b]);
+    motion.update(true);
+    const clone = a.clones[0]!;
+    motion.suspend();
+    expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce();
+    expect(parent.children.includes(clone)).toBe(false);
+    motion.update(false);
+    motion.suspend();
+    layout([c]);
+    motion.update(true);
+    expect(b.clones).toHaveLength(0);
+    expect(c.animations).toHaveLength(0);
+    layout([c, a]);
+    motion.update(true);
+    expect(a.animate).toHaveBeenCalledWith([{ opacity: 0 }, { opacity: 1 }], {
+      duration: 150,
+      easing: "ease-out",
+    });
+    motion.dispose();
+    expect(a.animations.at(-1)!.cancel).toHaveBeenCalledOnce();
+  });
+
+  it("carries entry opacity into a quick exit and removes artifacts on a silent update", () => {
+    const a = new TestRow("a");
+    const marker = new TestRow("boundary", 0);
+    const { motion, layout, parent } = fixture([marker]);
+    motion.update(false);
+    layout([marker, a]);
+    motion.update(true);
+    a.animations[0]!.progress = 0.4;
+    layout([]);
+    motion.update(true);
+    expect(marker.clones).toHaveLength(0);
+    const clone = a.clones[0]!;
+    expect(clone.animate).toHaveBeenCalledWith([{ opacity: 0.4 }, { opacity: 0 }], {
+      duration: 150,
+      easing: "ease-out",
+    });
+    motion.update(false);
+    expect(parent.children).toEqual([]);
+    expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce();
+  });
+
+  it("respects reduced motion while keeping the next baseline fresh", () => {
+    const a = new TestRow("a");
+    const b = new TestRow("b");
+    const { motion, layout, media } = fixture([a, b]);
+    motion.update(true);
+    media.matches = true;
+    layout([b, a]);
+    motion.update(true);
+    expect(a.animate).not.toHaveBeenCalled();
+    media.matches = false;
+    layout([a, b]);
+    motion.update(true);
+    expectMove(a, 83);
+    expectMove(b, -83);
+  });
+});
```

## apps/web/src/components/Sidebar.motion.ts

Status: added; +168; −0

```diff
@@ -1,0 +1,168 @@
+const motionTiming = { duration: 150, easing: "ease-out" };
+
+type RowPosition = { top: number; left: number; width: number; height: number };
+
+function progress(animation: Animation) {
+  return animation.playState === "finished"
+    ? 1
+    : (animation.effect?.getComputedTiming().progress ?? 0);
+}
+
+/** Animate rows between their layout positions. The list must be
+ * positioned so every direct child's offsetTop has the same origin. */
+export function createSidebarListMotion(parent: HTMLUListElement) {
+  let positions: Map<HTMLElement, RowPosition> | null = null;
+  let disposed = false;
+  const reducedMotion = parent.ownerDocument.defaultView?.matchMedia(
+    "(prefers-reduced-motion: reduce)",
+  );
+  const running = new Map<HTMLElement, { animation: Animation; offset: number }>();
+  const entering = new Map<HTMLElement, Animation>();
+  const exiting = new Map<HTMLElement, Animation>();
+
+  const remainingOffset = (node: HTMLElement) => {
+    const current = running.get(node);
+    return current ? current.offset * (1 - progress(current.animation)) : 0;
+  };
+  const clearFades = () => {
+    for (const animation of [...entering.values(), ...exiting.values()]) animation.cancel();
+    for (const node of exiting.keys()) node.remove();
+    entering.clear();
+    exiting.clear();
+  };
+  const fadeOut = (node: HTMLElement, position: RowPosition) => {
+    if (position.height === 0) return;
+    // React owns the removed row; only a noninteractive copy stays for the fade.
+    const clone = node.cloneNode(true) as HTMLElement;
+    for (const element of [clone, ...clone.querySelectorAll("*")]) {
+      for (const attribute of Array.from(element.attributes)) {
+        if (
+          (attribute.name === "id" && element.namespaceURI !== "http://www.w3.org/2000/svg") ||
+          attribute.name === "data-thread-item" ||
+          attribute.name === "data-thread-selection-safe" ||
+          attribute.name === "data-testid"
+        ) {
+          element.removeAttribute(attribute.name);
+        }
+      }
+    }
+    clone.setAttribute("aria-hidden", "true");
+    clone.inert = true;
+    Object.assign(clone.style, {
+      position: "absolute",
+      top: `${position.top + remainingOffset(node)}px`,
+      left: `${position.left}px`,
+      width: `${position.width}px`,
+      height: `${position.height}px`,
+      margin: "0",
+      boxSizing: "border-box",
+      contentVisibility: "visible",
+      transform: "none",
+      transition: "none",
+      pointerEvents: "none",
+    });
+    parent.append(clone);
+    const entry = entering.get(node);
+    const animation = clone.animate(
+      [{ opacity: entry ? progress(entry) : 1 }, { opacity: 0 }],
+      motionTiming,
+    );
+    exiting.set(clone, animation);
+    animation.addEventListener(
+      "finish",
+      () => {
+        clone.remove();
+        exiting.delete(clone);
+      },
+      { once: true },
+    );
+  };
+
+  const cancel = (node: HTMLElement) => {
+    running.get(node)?.animation.cancel();
+    running.delete(node);
+  };
+  const suspend = () => {
+    for (const node of running.keys()) cancel(node);
+    clearFades();
+    positions = null;
+  };
+
+  return {
+    update(animate: boolean) {
+      if (disposed) return;
+      const next = new Map(
+        Array.from(parent.children)
+          .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node))
+          .map((node) => [
+            node,
+            {
+              top: node.offsetTop,
+              left: node.offsetLeft,
+              width: node.offsetWidth,
+              height: node.offsetHeight,
+            },
+          ]),
+      );
+      const shouldAnimate = animate && positions !== null && !reducedMotion?.matches;
+      if (!shouldAnimate) clearFades();
+      else {
+        for (const [node, position] of positions!) {
+          if (!next.has(node)) fadeOut(node, position);
+        }
+      }
+      for (const [node, animation] of entering) {
+        if (!next.has(node)) {
+          animation.cancel();
+          entering.delete(node);
+        }
+      }
+      for (const node of running.keys()) {
+        if (!shouldAnimate || !next.has(node)) cancel(node);
+      }
+      if (shouldAnimate) {
+        for (const [node, position] of next) {
+          const previousTop = positions?.get(node)?.top;
+          if (previousTop === undefined) {
+            if (position.height > 0) {
+              const animation = node.animate([{ opacity: 0 }, { opacity: 1 }], motionTiming);
+              entering.set(node, animation);
+              animation.addEventListener(
+                "finish",
+                () => {
+                  if (entering.get(node) === animation) entering.delete(node);
+                },
+                { once: true },
+              );
+            }
+            continue;
+          }
+          if (previousTop === position.top) continue;
+          // Computed progress includes the effect's easing. Only our own
+          // translate is carried forward; dnd-kit's transforms are never read.
+          const offset = previousTop + remainingOffset(node) - position.top;
+          cancel(node);
+          if (offset === 0) continue;
+          const animation = node.animate(
+            [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }],
+            motionTiming,
+          );
+          running.set(node, { animation, offset });
+          animation.addEventListener(
+            "finish",
+            () => {
+              if (running.get(node)?.animation === animation) running.delete(node);
+            },
+            { once: true },
+          );
+        }
+      }
+      positions = next;
+    },
+    suspend,
+    dispose() {
+      suspend();
+      disposed = true;
+    },
+  };
+}
```

## apps/web/src/components/Sidebar.tsx

Status: modified; +1186; −624

```diff
@@ -1,20 +1,15 @@
-import { autoAnimate } from "@formkit/auto-animate";
 import { useAtomValue } from "@effect/atom-react";
 import * as Schema from "effect/Schema";
 import {
   DndContext,
   PointerSensor,
-  closestCenter,
   useSensor,
   useSensors,
   type DragEndEvent,
+  type DragOverEvent,
+  type DragStartEvent,
 } from "@dnd-kit/core";
-import {
-  SortableContext,
-  arrayMove,
+import { SortableContext, useSortable } from "@dnd-kit/sortable";
-  useSortable,
-  verticalListSortingStrategy,
-} from "@dnd-kit/sortable";
 import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
 import { CSS } from "@dnd-kit/utilities";
 import {
@@ -63,6 +58,7 @@
   memo,
   useCallback,
   useEffect,
+  useLayoutEffect,
   useMemo,
   useReducer,
   useRef,
@@ -77,6 +73,7 @@
   isAtomCommandInterrupted,
   settlePromise,
   squashAtomCommandFailure,
+  type AtomCommandResult,
 } from "@t3tools/client-runtime/state/runtime";
 import { isElectron } from "../env";
 import {
@@ -137,7 +134,8 @@
 import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
 import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
 import {
+  animateSidebarLayoutChanges,
+  applySidebarThreadDrop,
-  animatePinnedLayoutChanges,
   buildBulkTitleRegenerationContextMenuItem,
   buildBulkUnpinContextMenuItem,
   deleteSelectedThreadEntries,
@@ -148,14 +146,17 @@
   isSidebarNestedLinkClick,
   isTrailingDoubleClick,
   orderItemsByPreferredIds,
-  planPinnedReorder,
+  planSidebarThreadDrop,
   reduceSidebarProjectScopeMenuState,
   resolveAdjacentThreadId,
+  resolveSidebarDropTarget,
   resolveSidebarThreadStatus,
   searchSidebarThreadsByTitle,
   shouldCreateNewThreadInCurrentProject,
   shouldRecedeSidebarThread,
   resolveWorkingStartedAt,
+  sidebarListItemId,
+  sidebarMarkerId,
   sortLogicalProjectsForSidebar,
   sortPinnedThreadsForSidebar,
   sortSettledThreadsForSidebar,
@@ -163,8 +164,13 @@
   useRetainedValue,
   useSidebarRowSubscriptionLease,
   useThreadJumpHintVisibility,
+  type SidebarListItem,
+  type SidebarListMarker,
+  type SidebarSection,
 } from "./Sidebar.logic";
 import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
+import { createSidebarCollisionDetection, createSidebarSortingStrategy } from "./Sidebar.drag";
+import { createSidebarListMotion } from "./Sidebar.motion";
 import {
   ThreadWorktreeIndicator,
   prStatusIndicator,
@@ -473,23 +479,25 @@
   );
 }
 
+// Subset of useSortable applied to a thread row's root <li>. Listeners go
+// on the whole row (no dedicated handle): the pointer sensor's distance
-// Subset of useSortable applied to a pinned card's root <li>. Listeners go
-// on the whole card (no dedicated handle): the pointer sensor's distance
 // constraint keeps plain clicks working, and we skip dnd-kit's aria
-// attributes since there is no keyboard sensor and the card body already
+// attributes since there is no keyboard sensor and the row body already
 // carries its own button semantics.
+type SortableThreadRowBag = Pick<
-type SortablePinnedRowBag = Pick<
   ReturnType<typeof useSortable>,
   "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging"
 >;
 
+function SortableThreadRow(props: {
-function SortablePinnedThreadRow(props: {
   id: string;
-  children: (bag: SortablePinnedRowBag) => ReactNode;
+  disabled: boolean;
+  children: (bag: SortableThreadRowBag) => ReactNode;
 }) {
   const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
     id: props.id,
+    disabled: { draggable: props.disabled },
-    animateLayoutChanges: animatePinnedLayoutChanges,
+    animateLayoutChanges: animateSidebarLayoutChanges,
   });
   return props.children({ listeners, setNodeRef, transform, transition, isDragging });
 }
@@ -499,6 +507,152 @@
 const draftSurfaceClassName = "bg-amber-400/[0.04] hover:bg-amber-400/[0.08]";
 const draftPenClassName = "size-3 shrink-0 text-amber-600 dark:text-amber-300/80";
 
+// Structural list items — the section headers and the
+// empty-section placeholders — take part in the sortable list so they shift
+// with the rows and the gap can open on either side of them. They can't be
+// picked up, and a marker is the sortable `over` when the pointer is on it,
+// which resolveSidebarDropTarget turns into the section the gap sits in.
+function SortableSidebarMarker(props: {
+  marker: SidebarListMarker;
+  className?: string;
+  children?: ReactNode;
+  "data-testid"?: string;
+}) {
+  const { setNodeRef, transform, transition } = useSortable({
+    id: sidebarMarkerId(props.marker),
+    disabled: { draggable: true },
+    animateLayoutChanges: animateSidebarLayoutChanges,
+  });
+  return (
+    <li
+      ref={setNodeRef}
+      data-thread-selection-safe
+      data-testid={props["data-testid"]}
+      className={cn("list-none", props.className)}
+      style={{
+        transform: CSS.Translate.toString(transform),
+        transition,
+        visibility: transform?.scaleY === 0 ? "hidden" : undefined,
+      }}
+    >
+      {props.children}
+    </li>
+  );
+}
+
+// Empty targets stay mounted before pickup so starting a drag never changes
+// the list's measured positions.
+function SidebarSectionPlaceholder(props: {
+  marker: "active-placeholder" | "settled-placeholder";
+  label: string;
+  showHint: boolean;
+  isDropTarget: boolean;
+}) {
+  return (
+    <SortableSidebarMarker
+      marker={props.marker}
+      data-testid={`sidebar-${props.marker}`}
+      className={cn(
+        "mx-0.5 flex h-9 items-center justify-center rounded-md border border-dashed border-transparent text-xs text-sidebar-muted-foreground/60",
+        props.showHint && "border-sidebar-border",
+        props.isDropTarget && "border-primary/40 bg-primary/5 text-primary",
+      )}
+    >
+      {props.showHint ? props.label : null}
+    </SortableSidebarMarker>
+  );
+}
+
+// Boundary labels overlay the cards' padding during a drag. The measured
+// marker stays empty, so showing a label never pushes a row out of the way.
+function SidebarDragBoundary(props: {
+  marker: "pinned-header" | "pinned-divider";
+  label: string;
+  hint: string | null;
+  visible: boolean;
+  isDropTarget: boolean;
+}) {
+  return (
+    <SortableSidebarMarker
+      marker={props.marker}
+      data-testid={`sidebar-${props.marker}`}
+      className="pointer-events-none relative z-10 mx-0.5 h-0"
+    >
+      {props.visible ? (
+        <div className="absolute inset-x-2 top-0 flex h-4 -translate-y-1/2 items-center gap-1.5">
+          <span
+            className={cn(
+              "inline-flex h-4 shrink-0 items-center gap-2 rounded-sm border border-sidebar-border bg-sidebar px-1.5 text-[10px] leading-none font-medium text-sidebar-muted-foreground",
+              props.isDropTarget && "border-primary/40 text-primary",
+            )}
+          >
+            {props.label}
+            {props.hint ? <span className="font-normal">{props.hint}</span> : null}
+          </span>
+          <span
+            aria-hidden
+            className={cn("h-px flex-1 bg-sidebar-border", props.isDropTarget && "bg-primary/50")}
+          />
+        </div>
+      ) : null}
+    </SortableSidebarMarker>
+  );
+}
+
+// Shelf headers stay visible and keep their measured height while dragging.
+function SidebarSectionHeader(props: {
+  marker: "snoozed-header" | "settled-header";
+  label: string;
+  hint?: string | null;
+  isDropTarget?: boolean;
+  toggle: { expanded: boolean; onToggle: () => void };
+}) {
+  const snoozed = props.marker === "snoozed-header";
+  const className = cn(
+    "flex h-full w-full items-center gap-2 rounded-md border border-dashed border-transparent px-2 text-left text-xs font-medium",
+    snoozed ? "text-blue-600 dark:text-blue-400" : "text-sidebar-muted-foreground/60",
+    props.isDropTarget && "border-primary/40 bg-primary/5 text-primary",
+  );
+  const content = (
+    <>
+      <span className="shrink-0">{props.label}</span>
+      <span
+        aria-hidden
+        className={cn(
+          "h-px min-w-2 flex-1",
+          snoozed ? "bg-blue-500/20 dark:bg-blue-400/15" : "bg-sidebar-border/60",
+          props.isDropTarget && "bg-primary/30",
+        )}
+      />
+      {props.hint ? <span className="truncate text-[11px] font-normal">{props.hint}</span> : null}
+      <ChevronDownIcon
+        aria-hidden
+        className={cn(
+          "size-3 shrink-0 transition-transform",
+          props.toggle.expanded && "rotate-180",
+        )}
+      />
+    </>
+  );
+  return (
+    <SortableSidebarMarker
+      marker={props.marker}
+      data-testid={`sidebar-${props.marker}`}
+      className="mx-0.5 h-8"
+    >
+      <button
+        type="button"
+        onClick={props.toggle.onToggle}
+        aria-expanded={props.toggle.expanded}
+        data-testid={`sidebar-${snoozed ? "snoozed" : "settled"}-shelf-toggle`}
+        className={cn(className, "cursor-pointer")}
+      >
+        {content}
+      </button>
+    </SortableSidebarMarker>
+  );
+}
+
 // One unsent draft session the user has invested content in. Two lines,
 // nothing else: project name, then the typed prompt. All the draft's
 // settings (model, env mode, branch, worktree) still travel with it —
@@ -752,10 +906,11 @@
   // rows. The marker can unpin the thread when the server supports pinning.
   pinningSupported: boolean;
   isPinned: boolean;
+  // Present on rows whose server supports every drop outcome: dnd-kit
-  // Present only on pinned cards whose server supports reordering: dnd-kit
+  // sortable bag applied to the row root so the whole row drags (the
-  // sortable bag applied to the card root so the whole card drags (the
   // pointer sensor's distance constraint keeps plain clicks working).
-  sortable?: SortablePinnedRowBag | undefined;
+  sortable?: SortableThreadRowBag | undefined;
+  dropSection: SidebarSection | null;
   // Compact wake countdown ("2h") for rows in the snoozed shelf.
   snoozeWakeLabelText: string | null;
   // When a snooze ended (timer or early wake); drives the Woke pill until
@@ -1151,6 +1306,41 @@
       !isSelected &&
       "opacity-70 transition-opacity hover:opacity-100",
   );
+  // dnd-kit props for the row root. Same bag on both variants: every row in
+  // the list translates around the gap as the drag passes it.
+  const sortable = props.sortable;
+  const sortableRootProps = sortable
+    ? {
+        ref: sortable.setNodeRef,
+        style: {
+          transform: CSS.Translate.toString(sortable.transform),
+          transition: sortable.transition,
+          // A zero-height boundary also makes dnd-kit scale the source to
+          // zero. Only projected peers use scaleY as a visibility sentinel.
+          visibility:
+            !sortable.isDragging && sortable.transform?.scaleY === 0
+              ? ("hidden" as const)
+              : undefined,
+        },
+        ...sortable.listeners,
+      }
+    : {};
+  const dragDestination =
+    sortable?.isDragging && props.dropSection !== null ? (
+      <span
+        role="status"
+        className="pointer-events-none ml-auto inline-flex h-5 shrink-0 items-center gap-1 rounded-sm border border-primary/30 bg-sidebar px-1.5 text-[11px] font-medium text-primary"
+      >
+        <span aria-hidden>→</span>
+        {props.dropSection === "pinned"
+          ? "Pinned"
+          : props.dropSection === "active"
+            ? "Active"
+            : props.dropSection === "settled"
+              ? "Settled"
+              : "Snoozed"}
+      </span>
+    ) : null;
 
   const title = isRenaming ? (
     <input
@@ -1282,9 +1472,13 @@
     return (
       <li
         data-thread-item
+        {...sortableRootProps}
+        className={cn(
+          "list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]",
+          sortable?.isDragging && "z-20 opacity-80",
+        )}
-        className="list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]"
       >
-        <Tooltip>
+        <Tooltip disabled={sortable?.isDragging}>
           <TooltipTrigger
             render={
               <div
@@ -1332,93 +1526,95 @@
               remain visible AND clickable while the row is hovered. Only
               the time/jump label yields to the settle affordance. */}
             {prBadge}
+            {dragDestination ?? (
+              <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
+                <span
+                  className={cn(
+                    "inline-flex justify-end tabular-nums text-secondary-label transition-opacity",
+                    !isWoke && "group-hover/sidebar-row:opacity-0",
+                  )}
+                >
+                  {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
+                    // Snoozed rows show when they come BACK, not when they were
+                    // last touched — the return ticket is the row's whole story.
+                    <span className="text-xs text-blue-600 tabular-nums dark:text-blue-400">
+                      {props.snoozeWakeLabelText}
+                    </span>
+                  ) : isWoke ? (
+                    // A wake can land straight in the settled tail (e.g. PR
+                    // merged while snoozed); the signal must survive the trip.
+                    <Tooltip>
+                      <TooltipTrigger
+                        render={
+                          <button
+                            type="button"
+                            aria-label="Dismiss Woke notification"
+                            onClick={handleAcknowledgeWokeClick}
+                            className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"
+                          >
+                            <AlarmClockIcon aria-hidden className="size-3" />
+                            <span role="status">Woke</span>
+                          </button>
+                        }
+                      />
+                      <TooltipPopup side="top">Dismiss Woke notification</TooltipPopup>
+                    </Tooltip>
+                  ) : (
+                    <span className="text-xs">
+                      {variantAction === "unsettle"
+                        ? settledTimeLabel(thread)
+                        : threadTimeLabel(thread)}
+                    </span>
+                  )}
+                </span>
+                {variantAction === "unsnooze" ? (
+                  !props.snoozeSupported ? null : (
+                    <button
+                      type="button"
+                      aria-label="Wake thread now"
-            <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
-              <span
-                className={cn(
-                  "inline-flex justify-end tabular-nums text-secondary-label transition-opacity",
-                  !isWoke && "group-hover/sidebar-row:opacity-0",
-                )}
-              >
-                {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
-                  // Snoozed rows show when they come BACK, not when they were
-                  // last touched — the return ticket is the row's whole story.
-                  <span className="text-xs text-blue-600 tabular-nums dark:text-blue-400">
-                    {props.snoozeWakeLabelText}
-                  </span>
-                ) : isWoke ? (
+                      onClick={handleUnsnoozeClick}
+                      className={cn(
+                        "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100",
+                        isWoke && "group-hover/sidebar-row:static",
-                  // A wake can land straight in the settled tail (e.g. PR
+                      )}
-                  // merged while snoozed); the signal must survive the trip.
+                    >
+                      <AlarmClockOffIcon className="mb-px size-3" />
+                    </button>
+                  )
+                ) : !props.settlementSupported ? null : variantAction === "unsettle" ? (
                   <Tooltip>
                     <TooltipTrigger
                       render={
                         <button
                           type="button"
-                          aria-label="Dismiss Woke notification"
-                          onClick={handleAcknowledgeWokeClick}
-                          className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"
-                        >
+                          aria-label="Un-settle thread"
+                          onClick={handleUnsettleClick}
-                          <AlarmClockIcon aria-hidden className="size-3" />
-                          <span role="status">Woke</span>
-                        </button>
+                          className={cn(
+                            "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100",
+                            isWoke && "group-hover/sidebar-row:static",
+                          )}
+                        />
                       }
-                    />
-                    <TooltipPopup side="top">Dismiss Woke notification</TooltipPopup>
+                    >
+                      <Undo2Icon className="mb-px size-3.5" />
+                    </TooltipTrigger>
+                    <TooltipPopup side="top">Un-settle thread</TooltipPopup>
                   </Tooltip>
                 ) : (
-                  <span className="text-xs">
-                    {variantAction === "unsettle"
-                      ? settledTimeLabel(thread)
-                      : threadTimeLabel(thread)}
-                  </span>
-                )}
-              </span>
-              {variantAction === "unsnooze" ? (
-                !props.snoozeSupported ? null : (
                   <button
                     type="button"
-                    aria-label="Wake thread now"
-                    onClick={handleUnsnoozeClick}
+                    aria-label="Settle thread"
+                    onClick={handleSettleClick}
                     className={cn(
-                      "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100",
+                      "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100",
                       isWoke && "group-hover/sidebar-row:static",
                     )}
                   >
-                    <AlarmClockOffIcon className="mb-px size-3" />
+                    <CheckIcon className="size-3" />
                   </button>
+                )}
-                )
-              ) : !props.settlementSupported ? null : variantAction === "unsettle" ? (
+              </span>
+            )}
-                <Tooltip>
-                  <TooltipTrigger
-                    render={
-                      <button
-                        type="button"
-                        aria-label="Un-settle thread"
-                        onClick={handleUnsettleClick}
-                        className={cn(
-                          "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100",
-                          isWoke && "group-hover/sidebar-row:static",
-                        )}
-                      />
-                    }
-                  >
-                    <Undo2Icon className="mb-px size-3.5" />
-                  </TooltipTrigger>
-                  <TooltipPopup side="top">Un-settle thread</TooltipPopup>
-                </Tooltip>
-              ) : (
-                <button
-                  type="button"
-                  aria-label="Settle thread"
-                  onClick={handleSettleClick}
-                  className={cn(
-                    "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100",
-                    isWoke && "group-hover/sidebar-row:static",
-                  )}
-                >
-                  <CheckIcon className="size-3" />
-                </button>
-              )}
-            </span>
             {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null}
           </TooltipTrigger>
           {detailsTooltip}
@@ -1429,26 +1625,16 @@
 
   const diff = latestTurnDiff(thread);
 
-  const sortable = props.sortable;
   return (
     <li
       data-thread-item
-      ref={sortable?.setNodeRef}
-      style={
-        sortable
-          ? {
-              transform: CSS.Translate.toString(sortable.transform),
-              transition: sortable.transition,
-            }
-          : undefined
+      {...sortableRootProps}
-      }
-      {...(sortable?.listeners ?? {})}
       className={cn(
         "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]",
         sortable?.isDragging && "z-20 opacity-80",
       )}
     >
+      <Tooltip disabled={snoozeMenuOpen || sortable?.isDragging}>
-      <Tooltip disabled={snoozeMenuOpen}>
         <TooltipTrigger
           render={
             <div
@@ -1493,125 +1679,127 @@
                   actions on hover/keyboard focus or while the popover is open. Keeping
                   the hidden state out of flow lets the project label reclaim
                   space without either state overlapping it. */}
+              {dragDestination ?? (
+                <span className="group/sidebar-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
+                  {/* Read-only status labels yield to the hover actions. Woke is
-              <span className="group/sidebar-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
-                {/* Read-only status labels yield to the hover actions. Woke is
                     itself an action, so it stays pointer-enabled and visible
                     while the other controls appear beside it. */}
-                <span
-                  className={cn(
-                    isWokeStatus
-                      ? "pointer-events-auto"
-                      : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0",
-                    "flex items-center self-center justify-self-end tabular-nums text-secondary-label transition-opacity",
-                    snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
-                  )}
-                >
-                  {topStatus ? (
-                    isWokeStatus ? (
-                      <Tooltip>
-                        <TooltipTrigger
-                          render={
-                            <button
-                              type="button"
-                              aria-label="Dismiss Woke notification"
-                              onClick={handleAcknowledgeWokeClick}
-                              className={cn(
-                                "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
-                                topStatus.className,
-                              )}
-                            >
-                              <AlarmClockIcon aria-hidden className="size-4 shrink-0" />
-                              <span role="status">{topStatus.label}</span>
-                            </button>
-                          }
-                        />
-                        <TooltipPopup side="top">Dismiss Woke notification</TooltipPopup>
-                      </Tooltip>
-                    ) : (
-                      <span
-                        className={cn(
-                          "inline-flex items-center gap-1 font-medium",
-                          topStatus.className,
-                        )}
-                      >
-                        {topStatus.icon === "working" ? (
-                          <CircleDashedIcon aria-hidden className="size-4 shrink-0" />
-                        ) : topStatus.icon === "done" ? (
-                          <CircleCheckIcon aria-hidden className="size-4 shrink-0" />
-                        ) : null}
-                        {/* The label alone is the live region: a role="status"
-                            wrapper around the ticking duration would make
-                            screen readers announce every second. */}
-                        <span role="status">{topStatus.label}</span>
-                        {status === "working" ? (
-                          <span aria-hidden>
-                            <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
-                          </span>
-                        ) : null}
-                      </span>
-                    )
-                  ) : (
-                    threadTimeLabel(thread)
-                  )}
-                </span>
-                {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? (
                   <span
                     className={cn(
+                      isWokeStatus
+                        ? "pointer-events-auto"
+                        : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0",
+                      "flex items-center self-center justify-self-end tabular-nums text-secondary-label transition-opacity",
-                      // focus-visible, not focus-within: a mouse click leaves
+                      snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
-                      // the Settle button focused, and a plain focus-within
-                      // would keep the controls pinned over the status label
-                      // once the pointer moves away (e.g. after a failed
-                      // settle) instead of cross-fading back.
-                      "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:static group-hover/sidebar-row:opacity-100",
-                      snoozeMenuOpen && "pointer-events-auto static opacity-100",
                     )}
                   >
+                    {topStatus ? (
+                      isWokeStatus ? (
+                        <Tooltip>
+                          <TooltipTrigger
+                            render={
+                              <button
+                                type="button"
+                                aria-label="Dismiss Woke notification"
+                                onClick={handleAcknowledgeWokeClick}
-                    {hasUnsentDraft ? (
-                      <Tooltip>
-                        <TooltipTrigger
-                          render={
-                            <button
-                              type="button"
-                              aria-label="Discard draft"
+                                className={cn(
+                                  "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
+                                  topStatus.className,
+                                )}
-                              onClick={handleDiscardDraftClick}
-                              className="inline-flex cursor-pointer items-center rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
-                            />
+                              >
+                                <AlarmClockIcon aria-hidden className="size-4 shrink-0" />
+                                <span role="status">{topStatus.label}</span>
+                              </button>
+                            }
+                          />
+                          <TooltipPopup side="top">Dismiss Woke notification</TooltipPopup>
+                        </Tooltip>
+                      ) : (
+                        <span
+                          className={cn(
+                            "inline-flex items-center gap-1 font-medium",
+                            topStatus.className,
+                          )}
-                          }
                         >
-                          <XIcon className="size-3.5" />
-                        </TooltipTrigger>
-                        <TooltipPopup side="top">Discard draft</TooltipPopup>
-                      </Tooltip>
-                    ) : null}
-                    {showSnoozeButton ? (
-                      <SnoozePopoverButton
-                        open={snoozeMenuOpen}
-                        onOpenChange={setSnoozeMenuOpen}
-                        onSnooze={handleSnoozePreset}
-                        timestampFormat={props.timestampFormat}
-                      />
-                    ) : null}
-                    {props.settlementSupported ? (
-                      <Tooltip>
-                        <TooltipTrigger
-                          render={
-                            <button
-                              type="button"
-                              aria-label="Settle thread"
-                              onClick={handleSettleClick}
-                              className="-mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
-                            />
-                          }
-                        >
-                          <CheckIcon className="size-3.5" />
-                          Settle
-                        </TooltipTrigger>
+                          {topStatus.icon === "working" ? (
+                            <CircleDashedIcon aria-hidden className="size-4 shrink-0" />
+                          ) : topStatus.icon === "done" ? (
-                        <TooltipPopup>Settle thread</TooltipPopup>
-                      </Tooltip>
-                    ) : null}
+                            <CircleCheckIcon aria-hidden className="size-4 shrink-0" />
+                          ) : null}
+                          {/* The label alone is the live region: a role="status"
+                            wrapper around the ticking duration would make
+                            screen readers announce every second. */}
+                          <span role="status">{topStatus.label}</span>
+                          {status === "working" ? (
+                            <span aria-hidden>
+                              <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
+                            </span>
+                          ) : null}
+                        </span>
+                      )
+                    ) : (
+                      threadTimeLabel(thread)
+                    )}
                   </span>
+                  {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? (
+                    <span
-                ) : null}
+                      className={cn(
-              </span>
+                        // focus-visible, not focus-within: a mouse click leaves
+                        // the Settle button focused, and a plain focus-within
+                        // would keep the controls pinned over the status label
+                        // once the pointer moves away (e.g. after a failed
+                        // settle) instead of cross-fading back.
+                        "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:static group-hover/sidebar-row:opacity-100",
+                        snoozeMenuOpen && "pointer-events-auto static opacity-100",
+                      )}
+                    >
+                      {hasUnsentDraft ? (
+                        <Tooltip>
+                          <TooltipTrigger
+                            render={
+                              <button
+                                type="button"
+                                aria-label="Discard draft"
+                                onClick={handleDiscardDraftClick}
+                                className="inline-flex cursor-pointer items-center rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
+                              />
+                            }
+                          >
+                            <XIcon className="size-3.5" />
+                          </TooltipTrigger>
+                          <TooltipPopup side="top">Discard draft</TooltipPopup>
+                        </Tooltip>
+                      ) : null}
+                      {showSnoozeButton ? (
+                        <SnoozePopoverButton
+                          open={snoozeMenuOpen}
+                          onOpenChange={setSnoozeMenuOpen}
+                          onSnooze={handleSnoozePreset}
+                          timestampFormat={props.timestampFormat}
+                        />
+                      ) : null}
+                      {props.settlementSupported ? (
+                        <Tooltip>
+                          <TooltipTrigger
+                            render={
+                              <button
+                                type="button"
+                                aria-label="Settle thread"
+                                onClick={handleSettleClick}
+                                className="-mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
+                              />
+                            }
+                          >
+                            <CheckIcon className="size-3.5" />
+                            Settle
+                          </TooltipTrigger>
+                          <TooltipPopup>Settle thread</TooltipPopup>
+                        </Tooltip>
+                      ) : null}
+                    </span>
+                  ) : null}
+                </span>
+              )}
             </div>
             <div className="mt-1 flex min-w-0">
               {title}
@@ -1834,8 +2022,10 @@
     snoozeThread,
     unsnoozeThread,
     pinThread,
+    unpinThread,
     confirmAndUnpinThread,
     reorderPinnedThread,
+    reorderActiveThread,
     archiveThread,
     deleteThread,
   } = useThreadActions();
@@ -2196,13 +2386,27 @@
     [openProjectSettings],
   );
 
-  // Settled threads stay in the live shell stream (settled ≠ archived), so
+  // Keep a dropped row at its destination while its server applies the
+  // lifecycle command and any order-key writes. The next pickup waits for
-  // the partition works directly off live shells: no archived-snapshot
-  // merging, no optimistic holds. Archived threads remain hidden here —
-  // archive keeps its original "remove from sidebar" meaning.
+  // this hold so a second drop cannot replace an unconfirmed placement.
+  const [optimisticDrop, setOptimisticDrop] = useState<{
+    readonly key: string;
+    readonly sourceSection: SidebarSection;
+    readonly section: "pinned" | "active" | "settled";
+    readonly occurredAt: string;
+    readonly clearsSnooze: boolean;
+    /** Full destination order for pinned and active drops. */
+    readonly order: readonly string[] | null;
+    /** Destination order keys before the drop, to recognize concurrent writes. */
+    readonly keysAtDrop: ReadonlyMap<string, string | null>;
+    /** The keys this drop writes (one per planned assignment). The
+        override holds until all of them appear in canonical state. */
+    readonly assignedKeys: ReadonlyMap<string, string>;
+  } | null>(null);
   const {
     pinnedThreads,
-    reorderablePinnedKeys,
+    draggableThreadKeys,
+    activeReorderableThreadKeys,
     activeThreads,
     snoozedThreads,
     settledThreads,
@@ -2224,17 +2428,42 @@
     const active: EnvironmentThreadShell[] = [];
     const snoozed: EnvironmentThreadShell[] = [];
     const settled: EnvironmentThreadShell[] = [];
+    const draggable = new Set<string>();
+    const activeReorderable = new Set<string>();
     for (const thread of visible) {
+      const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities;
       // Threads on servers without the settlement capability (old server,
       // or descriptor not loaded yet) never classify as settled: the user
       // could neither un-settle nor pin them, so auto-settling them would
       // strand rows in a tail with no working affordances.
-      const supportsSettlement =
-        serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true;
-      const supportsSnooze =
+      const supportsSettlement = capabilities?.threadSettlement === true;
-        serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
-      // Snooze outranks settlement and pinning until the thread wakes.
-      if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
+      const supportsSnooze = capabilities?.threadSnooze === true;
+      const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
+      if (capabilities?.threadActiveReorder === true) activeReorderable.add(threadKey);
+      // Older servers retain their existing drag actions. Active placement
+      // additionally requires its own ordering capability at the drop target.
+      if (capabilities?.threadPinning === true && capabilities.threadPinReorder === true) {
+        draggable.add(threadKey);
+      }
+      if (optimisticDrop?.key === threadKey) {
+        const projected = applySidebarThreadDrop(
+          thread,
+          optimisticDrop.section,
+          optimisticDrop.occurredAt,
+          optimisticDrop.assignedKeys.get(threadKey),
+        );
+        (optimisticDrop.section === "pinned"
+          ? pinned
+          : optimisticDrop.section === "settled"
+            ? settled
+            : active
+        ).push(
+          optimisticDrop.clearsSnooze
+            ? projected
+            : { ...projected, snoozedAt: thread.snoozedAt, snoozedUntil: thread.snoozedUntil },
+        );
+      } else if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
+        // Snooze outranks settlement and pinning until the thread wakes.
         snoozed.push(thread);
       } else if (supportsSettlement && thread.settledOverride === "settled") {
         settled.push(thread);
@@ -2249,18 +2478,27 @@
     // Server capability only gates DRAGGING — it must not influence the
     // sort, or mixed-version fleets would render different pinned orders on
     // web and mobile from the same data.
+    const sortedPinned = sortPinnedThreadsForSidebar(pinned);
+    const sortedActive = sortThreadsForSidebar(active);
     return {
+      pinnedThreads:
+        optimisticDrop?.section !== "pinned" || optimisticDrop.order === null
+          ? sortedPinned
+          : orderItemsByPreferredIds({
+              items: sortedPinned,
+              preferredIds: optimisticDrop.order,
+              getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+            }),
+      draggableThreadKeys: draggable,
+      activeReorderableThreadKeys: activeReorderable,
+      activeThreads:
-      pinnedThreads: sortPinnedThreadsForSidebar(pinned),
-      reorderablePinnedKeys: new Set(
-        pinned
+        optimisticDrop?.section !== "active" || optimisticDrop.order === null
+          ? sortedActive
+          : orderItemsByPreferredIds({
+              items: sortedActive,
+              preferredIds: optimisticDrop.order,
+              getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+            }),
-          .filter(
-            (thread) =>
-              serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReorder ===
-              true,
-          )
-          .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
-      ),
-      activeThreads: sortThreadsForSidebar(active),
       // Soonest wake first: "what comes back next" is the shelf's question.
       snoozedThreads: snoozed.toSorted(
         (left, right) =>
@@ -2270,7 +2508,7 @@
       settledThreads: sortSettledThreadsForSidebar(settled),
       snoozeNow: preciseNow,
     };
-  }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]);
+  }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]);
 
   const threadSearchInputRef = useRef<HTMLInputElement>(null);
   const [threadSearchQuery, setThreadSearchQuery] = useState("");
@@ -2715,77 +2953,122 @@
     },
     [unsnoozeThread],
   );
-  // Drag-to-reorder for the pinned block. A drop computes ONE fractional key
-  // for the moved thread and sends it to that thread's own server (see
-  // planPinnedReorder for the keyless-neighbor materialization case, which
-  // instead rewrites every key in the section). The optimistic order keeps
+  const listMotionRef = useRef<ReturnType<typeof createSidebarListMotion> | null>(null);
+  const attachListMotionRef = useCallback((node: HTMLUListElement | null) => {
+    listMotionRef.current?.dispose();
+    listMotionRef.current = node === null ? null : createSidebarListMotion(node);
-  // the card where it was dropped until EVERY key the drop wrote is
-  // reflected in canonical state — a section rewrite is several sequential
-  // writes, and releasing on the first landed key would expose the
+    listMotionRef.current?.update(false);
+  }, []);
+
+  // Hold the chosen section and order until every key write arrives. This
+  // also covers first-time ordering, which assigns keys to keyless neighbors.
+  // A failed write, concurrent reorder, or membership change releases the hold.
+  const dndSensors = useSensors(
-  // half-written canonical order, reshuffling the block once per write.
-  // A failed write clears the override (the card snaps back) with a toast.
-  // A key we did NOT write landing (a concurrent client's reorder that must
-  // win) and ANY membership change (new pin, unpin, snooze/wake) also
-  // release it: the override can't say where members it never saw belong,
-  // and holding it would launder a stale order into later drags.
-  const pinnedDndSensors = useSensors(
     useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
   );
-  const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{
-    readonly order: readonly string[];
-    /** pinOrderKey per thread as of the drop — the baseline that tells a
-        concurrent client's write apart from one of our own landing. */
-    readonly keysAtDrop: ReadonlyMap<string, string | null>;
-    /** The keys this drop writes (one per planned assignment). The
-        override holds until all of them appear in canonical state. */
+  const [dragState, setDragState] = useState<{
+    readonly activeKey: string;
+    readonly activeSection: SidebarSection;
+    readonly occurredAt: string;
-    readonly assignedKeys: ReadonlyMap<string, string>;
+    readonly activationY: number | null;
   } | null>(null);
-  const orderedPinnedThreads = useMemo(() => {
-    if (optimisticPinnedOrder === null) return pinnedThreads;
-    return orderItemsByPreferredIds({
-      items: pinnedThreads,
-      preferredIds: optimisticPinnedOrder.order,
-      getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
-    });
-  }, [optimisticPinnedOrder, pinnedThreads]);
+  const [dragTargetSection, setDragTargetSection] = useState<SidebarSection | null>(null);
+  const sectionByThreadKey = useMemo(() => {
+    const map = new Map<string, SidebarSection>();
+    const add = (list: readonly EnvironmentThreadShell[], section: SidebarSection) => {
+      for (const thread of list) {
+        map.set(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), section);
+      }
+    };
+    add(pinnedThreads, "pinned");
+    add(activeThreads, "active");
+    add(snoozedThreads, "snoozed");
+    add(settledThreads, "settled");
+    return map;
+  }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads]);
+  const pinnedKeys = useMemo(
+    () =>
+      pinnedThreads.map((thread) =>
+        scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+      ),
+    [pinnedThreads],
+  );
+  const activeKeys = useMemo(
+    () =>
+      activeThreads.map((thread) =>
+        scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+      ),
+    [activeThreads],
+  );
   useEffect(() => {
-    if (optimisticPinnedOrder === null) return;
-    const canonical = pinnedThreads.filter((thread) =>
+    if (optimisticDrop === null) return;
+    const canonicalByKey = new Map(
+      threads.map((thread) => [
+        scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+        thread,
+      ]),
-      reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
     );
+    const thread = canonicalByKey.get(optimisticDrop.key);
+    if (thread === undefined || thread.archivedAt !== null) {
+      setOptimisticDrop(null);
+      return;
+    }
+    const canonicalSection = effectiveSnoozed(thread, { now: new Date().toISOString() })
+      ? "snoozed"
+      : thread.settledOverride === "settled"
+        ? "settled"
+        : thread.pinnedAt != null
+          ? "pinned"
+          : "active";
+    if (
+      canonicalSection !== optimisticDrop.sourceSection &&
+      canonicalSection !== optimisticDrop.section
+    ) {
+      setOptimisticDrop(null);
+      return;
+    }
+    if (optimisticDrop.order === null) {
+      // Settle also emits unpin/unsnooze events. Wait for the entire move
+      // before releasing the projected fields and sort timestamps.
+      if (
+        canonicalSection === optimisticDrop.section &&
-    const canonicalKeys = canonical.map((thread) =>
+        thread.pinnedAt == null &&
-      scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+        (!optimisticDrop.clearsSnooze || thread.snoozedUntil == null)
+      ) {
+        setOptimisticDrop(null);
+      }
+      return;
+    }
+    if (canonicalSection !== optimisticDrop.section) return;
+    if (optimisticDrop.clearsSnooze && thread.snoozedUntil != null) return;
+    const destinationKeys = optimisticDrop.section === "pinned" ? pinnedKeys : activeKeys;
+    const canonicalDestination = destinationKeys.flatMap((key) => {
+      const canonical = canonicalByKey.get(key);
+      return canonical === undefined ? [] : [canonical];
+    });
+    const keyByThread = new Map(
+      canonicalDestination.map((thread) => [
+        scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+        (optimisticDrop.section === "pinned" ? thread.pinOrderKey : thread.activeOrderKey) ?? null,
+      ]),
     );
+    const heldOrder = optimisticDrop.order;
+    const heldKeys = new Set(heldOrder);
-    // The override represents one drop against one snapshot of the world.
-    // Release it when the world moves on: membership changed (pin/unpin/
-    // snooze/wake — the override can't say where members it never saw
-    // belong), a key changed to something we did NOT write (a concurrent
-    // client's reorder that must win), every key we wrote has landed, or
-    // canonical already matches. Releasing on the FIRST landed key instead
-    // of the last exposes the half-written order mid-materialization and
-    // the block visibly reshuffles once per write.
     const membershipChanged =
-      canonicalKeys.length !== optimisticPinnedOrder.order.length ||
-      canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key));
-    const foreignKeyLanded = canonical.some((thread, index) => {
-      const threadKey = canonicalKeys[index]!;
-      const currentKey = thread.pinOrderKey ?? null;
-      if (currentKey === optimisticPinnedOrder.keysAtDrop.get(threadKey)) return false;
-      return currentKey !== optimisticPinnedOrder.assignedKeys.get(threadKey);
+      destinationKeys.length !== heldOrder.length ||
+      destinationKeys.some((key) => !heldKeys.has(key));
+    const foreignKeyLanded = destinationKeys.some((threadKey) => {
+      const currentKey = keyByThread.get(threadKey) ?? null;
+      if (currentKey === (optimisticDrop.keysAtDrop.get(threadKey) ?? null)) return false;
+      return currentKey !== optimisticDrop.assignedKeys.get(threadKey);
     });
-    const currentKeyByThreadKey = new Map(
-      canonical.map((thread, index) => [canonicalKeys[index]!, thread.pinOrderKey ?? null]),
+    const allAssignmentsLanded = [...optimisticDrop.assignedKeys].every(
+      ([threadKey, orderKey]) => keyByThread.get(threadKey) === orderKey,
     );
-    const allAssignmentsLanded = [...optimisticPinnedOrder.assignedKeys].every(
-      ([threadKey, orderKey]) => currentKeyByThreadKey.get(threadKey) === orderKey,
-    );
-    const orderConfirmed =
-      !membershipChanged &&
-      canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]);
+    if (membershipChanged || foreignKeyLanded || allAssignmentsLanded) {
+      setOptimisticDrop(null);
-    if (membershipChanged || foreignKeyLanded || allAssignmentsLanded || orderConfirmed) {
-      setOptimisticPinnedOrder(null);
     }
-  }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]);
+  }, [activeKeys, optimisticDrop, pinnedKeys, threads]);
   const attemptPin = useCallback(
     (threadRef: ScopedThreadRef) => {
       void (async () => {
@@ -2827,71 +3110,338 @@
     [confirmAndUnpinThread],
   );
 
-  const handlePinnedDragEnd = useCallback(
+  const handleThreadDragStart = useCallback(
+    (event: DragStartEvent) => {
-    (event: DragEndEvent) => {
       const activeKey = String(event.active.id);
-      const overKey = event.over === null ? null : String(event.over.id);
-      if (overKey === null || activeKey === overKey) return;
-      const reorderable = orderedPinnedThreads.filter((thread) =>
-        reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
-      );
-      const keys = reorderable.map((thread) =>
-        scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
-      );
-      const fromIndex = keys.indexOf(activeKey);
-      const toIndex = keys.indexOf(overKey);
-      if (fromIndex === -1 || toIndex === -1) return;
-      const newOrder = arrayMove([...keys], fromIndex, toIndex);
-      const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread]));
-      const keysAtDrop = new Map(
-        reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]),
-      );
+      const activeSection = sectionByThreadKey.get(activeKey);
+      if (activeSection === undefined) return;
+      // Stop normal section motion before dnd-kit measures the picked-up row.
+      listMotionRef.current?.suspend();
+      setDragState({
+        activeKey,
+        activeSection,
+        occurredAt: new Date().toISOString(),
+        activationY:
+          event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null,
+      });
+      setDragTargetSection(activeSection);
+    },
+    [sectionByThreadKey],
+  );
+  const handleThreadDragCancel = useCallback(() => {
+    listMotionRef.current?.suspend();
+    setDragState(null);
+    setDragTargetSection(null);
+  }, []);
+  // Include every visible row in the measured order. Older servers disable
+  // pickup on their rows without changing where those rows render.
+  const sidebarListItems = useMemo((): readonly SidebarListItem[] => {
+    const rowsOf = (
+      list: readonly EnvironmentThreadShell[],
+      section: SidebarSection,
+    ): SidebarListItem[] =>
+      list.map((thread) => {
+        const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
-      const assignments = planPinnedReorder({
-        orderedIds: newOrder,
-        keysById: keysAtDrop,
+        return { kind: "thread", key, section };
-        movedId: activeKey,
       });
+    if (
+      pinnedThreads.length +
+        activeThreads.length +
+        snoozedThreads.length +
+        settledThreads.length ===
+      0
+    ) {
+      return [];
+    }
+    const items: SidebarListItem[] = [{ kind: "marker", marker: "pinned-header" }];
+    const pinnedRows = rowsOf(pinnedThreads, "pinned");
+    items.push(...pinnedRows);
+    items.push({ kind: "marker", marker: "pinned-divider" });
+    const activeRows = rowsOf(activeThreads, "active");
+    if (activeRows.length === 0) {
+      items.push({ kind: "marker", marker: "active-placeholder" });
+    }
+    items.push(...activeRows);
+    if (snoozedThreads.length > 0) {
+      items.push({ kind: "marker", marker: "snoozed-header" });
+      items.push(...rowsOf(visibleSnoozedThreads, "snoozed"));
+    }
+    items.push({ kind: "marker", marker: "settled-header" });
+    const settledRows = rowsOf(renderedSettledThreads, "settled");
+    if (settledRows.length === 0) {
+      items.push({ kind: "marker", marker: "settled-placeholder" });
+    }
+    items.push(...settledRows);
+    return items;
+  }, [
+    activeThreads,
+    pinnedThreads,
+    renderedSettledThreads,
+    settledThreads.length,
+    snoozedThreads.length,
+    visibleSnoozedThreads,
+  ]);
+  const listMotionPaused = dragState !== null;
+  useLayoutEffect(() => {
+    // Drag release clears the baseline, so its commit cannot replay the
+    // sortable preview. Later thread actions can animate while writes settle.
+    // Draft navigation can reveal a frozen row without changing the draft count.
+    listMotionRef.current?.update(
+      !listMotionPaused && sidebarListItems.length + visibleDraftSessionCount > 0,
+    );
+  }, [listMotionPaused, routeDraftIdForRows, sidebarListItems, visibleDraftSessionCount]);
+  const handleThreadDragOver = useCallback(
+    (event: DragOverEvent) => {
+      const target = event.over
+        ? resolveSidebarDropTarget(sidebarListItems, String(event.active.id), String(event.over.id))
+        : null;
+      setDragTargetSection(target?.section ?? null);
+    },
+    [sidebarListItems],
+  );
+  const sortableIds = useMemo(() => sidebarListItems.map(sidebarListItemId), [sidebarListItems]);
+  const draggedSettledOrder = useMemo(() => {
+    const thread = dragState === null ? undefined : threadByKey.get(dragState.activeKey);
+    if (dragState === null || thread === undefined) return [];
+    const key = (candidate: EnvironmentThreadShell) =>
+      scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id));
+    return sortSettledThreadsForSidebar([
+      ...settledThreads.filter((candidate) => key(candidate) !== dragState.activeKey),
+      applySidebarThreadDrop(thread, "settled", dragState.occurredAt),
+    ]).map(key);
+  }, [dragState, settledThreads, threadByKey]);
+  const sidebarSortingStrategy = useMemo(
+    () =>
+      createSidebarSortingStrategy({
+        items: sidebarListItems,
+        settledOrder: draggedSettledOrder,
+        settledExpanded: settledShelfExpanded,
+        settledVisibleCount,
+        routeThreadKey,
+        snoozedThreadCount: snoozedThreads.length,
+      }),
+    [
+      draggedSettledOrder,
+      routeThreadKey,
+      settledShelfExpanded,
+      settledVisibleCount,
+      sidebarListItems,
+      snoozedThreads.length,
+    ],
+  );
+  // Hidden and filtered threads keep their keys. Reserve those slots without
+  // including the rows in the visible drop order or writing to them.
+  const { pinnedKeysById, activeKeysById } = useMemo(
+    () => ({
+      pinnedKeysById: new Map(
+        threads.map((thread) => [
+          scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+          thread.pinOrderKey ?? null,
+        ]),
+      ),
+      activeKeysById: new Map(
+        threads.map((thread) => [
+          scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+          thread.activeOrderKey ?? null,
+        ]),
+      ),
+    }),
+    [threads],
+  );
+  const dndCollisionDetection = useMemo(() => {
+    if (dragState === null) return createSidebarCollisionDetection(() => true);
+    const source = threadByKey.get(dragState.activeKey);
+    if (source === undefined) return createSidebarCollisionDetection(() => false);
+    return createSidebarCollisionDetection(
+      (id) => {
+        const target = resolveSidebarDropTarget(sidebarListItems, dragState.activeKey, id);
+        if (target === null) return false;
+        return (
+          planSidebarThreadDrop({
+            activeKey: dragState.activeKey,
+            activeSection: dragState.activeSection,
+            activePinned: source.pinnedAt != null,
+            activeSettled: source.settledOverride === "settled",
+            supportsSettlement:
+              serverConfigs.get(source.environmentId)?.environment.capabilities.threadSettlement ===
+              true,
+            target,
+            pinnedOrder: pinnedKeys,
+            pinnedKeysById,
+            reorderableKeys: draggableThreadKeys,
+            activeOrder: activeKeys,
+            activeKeysById,
+            activeReorderableKeys: activeReorderableThreadKeys,
+          }).kind !== "none"
+        );
+      },
+      { emptyPins: pinnedKeys.length === 0, activationY: dragState.activationY },
+    );
+  }, [
+    activeKeysById,
+    pinnedKeysById,
+    serverConfigs,
-      if (assignments.length === 0) return;
-      setOptimisticPinnedOrder({
-        order: newOrder,
-        keysAtDrop,
-        assignedKeys: new Map(
-          assignments.map((assignment) => [assignment.id, assignment.orderKey]),
-        ),
+    activeKeys,
+    activeReorderableThreadKeys,
+    dragState,
+    draggableThreadKeys,
+    pinnedKeys,
+    sidebarListItems,
+    threadByKey,
+  ]);
+  const handleThreadDragEnd = useCallback(
+    (event: DragEndEvent) => {
+      listMotionRef.current?.suspend();
+      setDragState(null);
+      setDragTargetSection(null);
+      const activeKey = String(event.active.id);
+      const activeSection = sectionByThreadKey.get(activeKey);
+      const target =
+        event.over === null
+          ? null
+          : resolveSidebarDropTarget(sidebarListItems, activeKey, String(event.over.id));
+      const activeThread = threadByKey.get(activeKey);
+      if (activeSection === undefined || target === null || activeThread === undefined) return;
+      const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id);
+      const plan = planSidebarThreadDrop({
+        activeKey,
+        activeSection,
+        activePinned: activeThread.pinnedAt != null,
+        activeSettled: activeThread.settledOverride === "settled",
+        supportsSettlement:
+          serverConfigs.get(activeThread.environmentId)?.environment.capabilities
+            .threadSettlement === true,
+        target,
+        pinnedOrder: pinnedKeys,
+        pinnedKeysById,
+        reorderableKeys: draggableThreadKeys,
+        activeOrder: activeKeys,
+        activeKeysById,
+        activeReorderableKeys: activeReorderableThreadKeys,
       });
+      if (plan.kind === "none") return;
+      if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return;
+      const assignments =
+        plan.kind === "pin"
+          ? [
+              ...(plan.orderKey === undefined ? [] : [{ id: activeKey, orderKey: plan.orderKey }]),
+              ...plan.extraAssignments,
+            ]
+          : plan.kind === "reorder-pinned" || plan.kind === "move-active"
+            ? plan.assignments
+            : [];
+      const drop = {
+        key: activeKey,
+        sourceSection: activeSection,
+        section: target.section,
+        occurredAt: new Date().toISOString(),
+        clearsSnooze:
+          plan.kind === "pin" ||
+          plan.kind === "settle" ||
+          (plan.kind === "move-active" && plan.unsnooze),
+        order: plan.kind === "settle" ? null : plan.order,
+        keysAtDrop: target.section === "active" ? activeKeysById : pinnedKeysById,
+        assignedKeys: new Map(assignments.map(({ id, orderKey }) => [id, orderKey])),
+      };
+      setOptimisticDrop(drop);
       void (async () => {
+        const run = async (
+          operation: Promise<AtomCommandResult<unknown, unknown>>,
-        // Sequential, stop on first failure. There is deliberately no
-        // rollback: every key write is a complete, valid placement on its
-        // own, so a partial materialization leaves a sensible order (and
-        // the next drag repairs the rest) — unwinding writes across
-        // servers would trade that for real inconsistency windows.
-        for (const assignment of assignments) {
-          const thread = threadByKey.get(assignment.id);
+          title: string,
+        ) => {
+          const result = await operation;
+          if (result._tag === "Success") return true;
+          // A late failure must not cancel a newer drag's preview.
+          setOptimisticDrop((current) => (current === drop ? null : current));
+          if (!isAtomCommandInterrupted(result)) {
-          if (thread === undefined) continue;
-          const result = await reorderPinnedThread(
-            scopeThreadRef(thread.environmentId, thread.id),
-            assignment.orderKey,
-          );
-          if (result._tag === "Failure") {
-            // Any failure — interrupted included — releases the override:
-            // a key that never lands would otherwise hold it until some
-            // unrelated world change came along.
-            setOptimisticPinnedOrder(null);
-            if (isAtomCommandInterrupted(result)) return;
             const error = squashAtomCommandFailure(result);
             toastManager.add(
               stackedThreadToast({
                 type: "error",
-                title: "Failed to reorder pinned threads",
+                title,
                 description: error instanceof Error ? error.message : "An error occurred.",
               }),
             );
+          }
+          return false;
+        };
+        switch (plan.kind) {
+          case "settle": {
+            settlingThreadKeysRef.current.add(activeKey);
+            const navigateAfterSettle = planForwardNavigation(activeKey);
+            const settled = await run(settleThread(threadRef), "Failed to settle thread").finally(
+              () => settlingThreadKeysRef.current.delete(activeKey),
+            );
+            if (settled && routeThreadKeyRef.current === activeKey) navigateAfterSettle?.();
             return;
           }
+          case "move-active":
+            // The drag expresses unpin intent; button/menu confirmation is unchanged.
+            if (plan.unpin && !(await run(unpinThread(threadRef), "Failed to unpin thread")))
+              return;
+            if (
+              plan.unsettle &&
+              !(await run(unsettleThread(threadRef), "Failed to un-settle thread"))
+            )
+              return;
+            if (plan.unsnooze && !(await run(unsnoozeThread(threadRef), "Failed to wake thread")))
+              return;
+            break;
+          case "pin":
+            if (
+              !(await run(
+                pinThread(
+                  threadRef,
+                  plan.orderKey === undefined ? {} : { orderKey: plan.orderKey },
+                ),
+                "Failed to pin thread",
+              ))
+            )
+              return;
+            break;
+          case "reorder-pinned":
+            break;
         }
+        // Stop on failure; each successful key write remains a valid placement.
+        const keyWrites = plan.kind === "pin" ? plan.extraAssignments : plan.assignments;
+        for (const assignment of keyWrites) {
+          const thread = threadByKey.get(assignment.id);
+          if (thread === undefined) continue;
+          if (
+            !(await run(
+              (plan.kind === "move-active" ? reorderActiveThread : reorderPinnedThread)(
+                scopeThreadRef(thread.environmentId, thread.id),
+                assignment.orderKey,
+              ),
+              plan.kind === "move-active"
+                ? "Failed to reorder active threads"
+                : "Failed to reorder pinned threads",
+            ))
+          )
+            return;
+        }
       })();
     },
+    [
-    [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys],
+      activeKeysById,
+      pinnedKeysById,
+      serverConfigs,
+      activeKeys,
+      activeReorderableThreadKeys,
+      draggableThreadKeys,
+      pinThread,
+      pinnedKeys,
+      planForwardNavigation,
+      reorderPinnedThread,
+      reorderActiveThread,
+      sectionByThreadKey,
+      settleThread,
+      sidebarListItems,
+      threadByKey,
+      unpinThread,
+      unsettleThread,
+      unsnoozeThread,
+    ],
   );
   // One snooze per thread at a time — same double-dispatch guard as settle.
   const snoozingThreadKeysRef = useRef(new Set<string>());
@@ -3535,11 +4085,6 @@
     updateThreadJumpHintsVisibility(shouldShowJumpHintsNow);
   }, [shouldShowJumpHintsNow, updateThreadJumpHintsVisibility]);
 
-  const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => {
-    if (!node) return;
-    autoAnimate(node, { duration: 150, easing: "ease-out" });
-  }, []);
-
   // New thread defaults to the project you're in (active thread's project,
   // falling back to the top project) — same resolution the command palette
   // uses. The command palette already offers a "New thread in..." submenu
@@ -3929,281 +4474,298 @@
               closeDelay={0}
               timeout={400}
             >
+              <DndContext
+                sensors={dndSensors}
+                collisionDetection={dndCollisionDetection}
+                modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
+                onDragStart={handleThreadDragStart}
+                onDragOver={handleThreadDragOver}
+                onDragCancel={handleThreadDragCancel}
+                onDragEnd={handleThreadDragEnd}
+              >
+                <SortableContext items={sortableIds} strategy={sidebarSortingStrategy}>
+                  <ul
+                    ref={attachListMotionRef}
+                    role="list"
+                    className="relative flex flex-col gap-px pt-2"
+                  >
+                    {(() => {
+                      const renderThreadRowInner = (
+                        thread: EnvironmentThreadShell,
+                        section: SidebarSection,
+                        sortable?: SortableThreadRowBag,
+                      ) => {
+                        const threadKey = scopedThreadKey(
+                          scopeThreadRef(thread.environmentId, thread.id),
+                        );
+                        // Settled and snoozed are the ONLY things that collapse a
+                        // row: every other thread is a full card. Density comes
+                        // from users (or the auto rules) actually parking work,
+                        // not from the sidebar second-guessing what still matters.
+                        const isCard = section === "active" || section === "pinned";
+                        const rowVariant = isCard ? "card" : "slim";
+                        return (
+                          <SidebarThreadRow
+                            // Fade between card and compact rows while the outer
+                            // sortable wrapper keeps its identity during a drag.
+                            key={`${threadKey}:${rowVariant}`}
+                            thread={thread}
+                            variant={rowVariant}
+                            // Snoozed rows wake, settled rows un-settle, and cards settle.
+                            variantAction={
+                              section === "snoozed"
+                                ? "unsnooze"
+                                : section === "settled"
+                                  ? "unsettle"
+                                  : "settle"
+                            }
+                            settlementSupported={
+                              serverConfigs.get(thread.environmentId)?.environment.capabilities
+                                .threadSettlement === true
+                            }
+                            snoozeSupported={
+                              serverConfigs.get(thread.environmentId)?.environment.capabilities
+                                .threadSnooze === true
+                            }
+                            pinningSupported={
+                              serverConfigs.get(thread.environmentId)?.environment.capabilities
+                                .threadPinning === true
+                            }
+                            isPinned={thread.pinnedAt != null}
+                            sortable={sortable}
+                            dropSection={
+                              dragState?.activeKey === threadKey ? dragTargetSection : null
+                            }
+                            snoozeWakeLabelText={
+                              section === "snoozed" && thread.snoozedUntil != null
+                                ? snoozeWakeLabel(thread.snoozedUntil, {
+                                    now: new Date().toISOString(),
+                                  })
+                                : null
+                            }
+                            // All sections: a woken thread can classify straight
+                            // into the settled tail (PR merged while snoozed), and
+                            // the wake signal must survive the trip. Still-snoozed
+                            // rows resolve to null on their own.
+                            wokeAt={threadWokeAt(thread, { now: snoozeNow })}
+                            isActive={routeThreadKey === threadKey}
+                            openPullRequestsInRightPanel={routeThreadRef !== null}
+                            jumpLabel={
+                              showThreadJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null
+                            }
+                            currentEnvironmentId={primaryEnvironmentId}
+                            environmentLabel={
+                              environmentLabelById.get(thread.environmentId) ?? null
+                            }
+                            environmentMachine={
+                              environmentMachineById.get(thread.environmentId) ?? "server"
+                            }
+                            projectCwd={
+                              projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
+                              null
+                            }
+                            projectFaviconPath={
+                              projectFaviconPathByKey.get(
+                                `${thread.environmentId}:${thread.projectId}`,
+                              ) ?? null
+                            }
+                            projectIcon={
+                              projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
+                              null
+                            }
+                            projectTitle={
+                              projectTitleByKey.get(
+                                `${thread.environmentId}:${thread.projectId}`,
+                              ) ?? null
-              <ul ref={attachListAutoAnimateRef} role="list" className="flex flex-col gap-px">
-                {(() => {
-                  const renderThreadRow = (
-                    thread: EnvironmentThreadShell,
-                    section: "pinned" | "active" | "snoozed" | "settled",
-                    sortable?: SortablePinnedRowBag,
-                  ) => {
-                    const threadKey = scopedThreadKey(
-                      scopeThreadRef(thread.environmentId, thread.id),
-                    );
-                    // Settled and snoozed are the ONLY things that collapse a
-                    // row: every other thread is a full card. Density comes
-                    // from users (or the auto rules) actually parking work,
-                    // not from the sidebar second-guessing what still matters.
-                    const isCard = section === "active" || section === "pinned";
-                    const rowVariant = isCard ? "card" : "slim";
-                    return (
-                      <SidebarThreadRow
-                        // Keyed per variant on purpose: when a thread settles,
-                        // the card fades out in place and the slim row fades
-                        // in at its settled position instead of one element
-                        // FLIP-sliding through every row in between (rows here
-                        // are translucent, so a crossing row reads as text
-                        // painted over text).
-                        key={`${threadKey}:${rowVariant}`}
-                        thread={thread}
-                        variant={rowVariant}
-                        // Snoozed rows wake, settled rows un-settle, and cards settle.
-                        variantAction={
-                          section === "snoozed"
-                            ? "unsnooze"
-                            : section === "settled"
-                              ? "unsettle"
-                              : "settle"
-                        }
-                        settlementSupported={
-                          serverConfigs.get(thread.environmentId)?.environment.capabilities
-                            .threadSettlement === true
-                        }
-                        snoozeSupported={
-                          serverConfigs.get(thread.environmentId)?.environment.capabilities
-                            .threadSnooze === true
-                        }
-                        pinningSupported={
-                          serverConfigs.get(thread.environmentId)?.environment.capabilities
-                            .threadPinning === true
-                        }
-                        isPinned={thread.pinnedAt != null}
-                        sortable={sortable}
-                        snoozeWakeLabelText={
-                          section === "snoozed" && thread.snoozedUntil != null
-                            ? snoozeWakeLabel(thread.snoozedUntil, {
-                                now: new Date().toISOString(),
-                              })
-                            : null
-                        }
-                        // All sections: a woken thread can classify straight
-                        // into the settled tail (PR merged while snoozed), and
-                        // the wake signal must survive the trip. Still-snoozed
-                        // rows resolve to null on their own.
-                        wokeAt={threadWokeAt(thread, { now: snoozeNow })}
-                        isActive={routeThreadKey === threadKey}
-                        openPullRequestsInRightPanel={routeThreadRef !== null}
-                        jumpLabel={
-                          showThreadJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null
-                        }
-                        currentEnvironmentId={primaryEnvironmentId}
-                        environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
-                        environmentMachine={
-                          environmentMachineById.get(thread.environmentId) ?? "server"
-                        }
-                        projectCwd={
-                          projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
-                        }
-                        projectFaviconPath={
-                          projectFaviconPathByKey.get(
-                            `${thread.environmentId}:${thread.projectId}`,
-                          ) ?? null
-                        }
-                        projectIcon={
-                          projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
-                          null
-                        }
-                        projectTitle={
-                          projectTitleByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
-                          null
-                        }
-                        projectDisplayName={
-                          projectDisplayNameByKey.get(
-                            `${thread.environmentId}:${thread.projectId}`,
-                          ) ?? null
-                        }
-                        providerEntryByInstanceId={
-                          providerEntriesByEnvironment.get(thread.environmentId) ??
-                          EMPTY_PROVIDER_ENTRIES
-                        }
-                        timestampFormat={timestampFormat}
-                        onThreadClick={handleThreadClick}
-                        onThreadActivate={navigateToThread}
-                        onStartRename={startThreadRename}
-                        onRenameTitleChange={setRenamingTitle}
-                        onCommitRename={commitThreadRename}
-                        onCancelRename={cancelThreadRename}
-                        isRenaming={renamingThreadKey === threadKey}
-                        renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}
-                        onContextMenu={handleThreadContextMenu}
-                        onSettle={attemptSettle}
-                        onUnsettle={attemptUnsettle}
-                        onSnooze={attemptSnooze}
-                        onUnsnooze={attemptUnsnooze}
-                        onUnpin={attemptUnpin}
-                        onAcknowledgeWoke={acknowledgeWoke}
-                      />
+                            }
+                            projectDisplayName={
+                              projectDisplayNameByKey.get(
+                                `${thread.environmentId}:${thread.projectId}`,
+                              ) ?? null
+                            }
+                            providerEntryByInstanceId={
+                              providerEntriesByEnvironment.get(thread.environmentId) ??
+                              EMPTY_PROVIDER_ENTRIES
+                            }
+                            timestampFormat={timestampFormat}
+                            onThreadClick={handleThreadClick}
+                            onThreadActivate={navigateToThread}
+                            onStartRename={startThreadRename}
+                            onRenameTitleChange={setRenamingTitle}
+                            onCommitRename={commitThreadRename}
+                            onCancelRename={cancelThreadRename}
+                            isRenaming={renamingThreadKey === threadKey}
+                            renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}
+                            onContextMenu={handleThreadContextMenu}
+                            onSettle={attemptSettle}
+                            onUnsettle={attemptUnsettle}
+                            onSnooze={attemptSnooze}
+                            onUnsnooze={attemptUnsnooze}
+                            onUnpin={attemptUnpin}
+                            onAcknowledgeWoke={acknowledgeWoke}
+                          />
+                        );
+                      };
-                    );
-                  };
-                  // Draft block above everything, then the pinned block:
-                  // full cards above the inbox, closed by a thin divider (the
-                  // pin glyphs carry the meaning, so no header text). Both
-                  // vanish entirely at count 0.
-                  // Pinned rows render in the one shared pinned order; only
-                  // reorder-capable rows register as sortable (legacy-server
-                  // pins render in place as plain rows).
-                  const items: ReactNode[] = [
-                    <SidebarDraftBlock
-                      key="draft-sessions"
-                      projectTitleByKey={projectTitleByKey}
-                      projectDisplayNameByKey={projectDisplayNameByKey}
+                      const renderThreadRow = (
+                        thread: EnvironmentThreadShell,
+                        section: SidebarSection,
+                      ) => {
+                        const threadKey = scopedThreadKey(
+                          scopeThreadRef(thread.environmentId, thread.id),
+                        );
+                        return (
+                          <SortableThreadRow
+                            key={threadKey}
+                            id={threadKey}
+                            disabled={
+                              !draggableThreadKeys.has(threadKey) || optimisticDrop !== null
+                            }
-                      projectCwdByKey={projectCwdByKey}
-                      projectFaviconPathByKey={projectFaviconPathByKey}
-                      projectIconByKey={projectIconByKey}
-                      scopedProjectKeys={scopedProjectKeys}
-                      routeDraftId={routeDraftIdForRows}
-                      onNavigateToDraft={navigateToDraft}
-                    />,
-                    pinnedThreads.length > 0 ? (
-                      <li key="pinned-dnd" className="list-none">
-                        <DndContext
-                          sensors={pinnedDndSensors}
-                          collisionDetection={closestCenter}
-                          modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
-                          onDragEnd={handlePinnedDragEnd}
-                        >
-                          <SortableContext
-                            items={orderedPinnedThreads
-                              .map((thread) =>
-                                scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
-                              )
-                              .filter((threadKey) => reorderablePinnedKeys.has(threadKey))}
-                            strategy={verticalListSortingStrategy}
                           >
-                            <ul
-                              role="list"
-                              aria-label="Pinned threads"
-                              className="flex flex-col gap-px"
-                            >
-                              {orderedPinnedThreads.map((thread) => {
-                                const threadKey = scopedThreadKey(
-                                  scopeThreadRef(thread.environmentId, thread.id),
+                            {(bag) => renderThreadRowInner(thread, section, bag)}
+                          </SortableThreadRow>
-                                );
-                                if (!reorderablePinnedKeys.has(threadKey)) {
-                                  return renderThreadRow(thread, "pinned");
+                        );
+                      };
+                      const from = dragState?.activeSection ?? null;
+                      const previewPinnedCount =
+                        pinnedThreads.length +
+                        (from !== "pinned" && dragTargetSection === "pinned" ? 1 : 0) -
+                        (from === "pinned" &&
+                        dragTargetSection !== null &&
+                        dragTargetSection !== "pinned"
+                          ? 1
+                          : 0);
+                      const activeHint =
+                        from === "pinned"
+                          ? "Drop to unpin"
+                          : from === "settled"
+                            ? "Drop to un-settle"
+                            : from === "snoozed"
+                              ? "Drop to wake"
+                              : null;
+                      const items: ReactNode[] = [
+                        <SidebarDraftBlock
+                          key="draft-sessions"
+                          projectTitleByKey={projectTitleByKey}
+                          projectDisplayNameByKey={projectDisplayNameByKey}
+                          projectCwdByKey={projectCwdByKey}
+                          projectFaviconPathByKey={projectFaviconPathByKey}
+                          projectIconByKey={projectIconByKey}
+                          scopedProjectKeys={scopedProjectKeys}
+                          routeDraftId={routeDraftIdForRows}
+                          onNavigateToDraft={navigateToDraft}
+                        />,
+                      ];
+                      for (const item of sidebarListItems) {
+                        if (item.kind === "thread") {
+                          items.push(renderThreadRow(threadByKey.get(item.key)!, item.section));
+                          continue;
+                        }
+                        switch (item.marker) {
+                          case "pinned-header":
+                            items.push(
+                              <SidebarDragBoundary
+                                key="pinned-header"
+                                marker="pinned-header"
+                                label="Pinned"
+                                hint={from !== null && from !== "pinned" ? "Drop to pin" : null}
+                                isDropTarget={dragTargetSection === "pinned"}
+                                visible={from !== null}
+                              />,
+                            );
+                            break;
+                          case "pinned-divider":
+                            items.push(
+                              <SidebarDragBoundary
+                                key="pinned-divider"
+                                marker="pinned-divider"
+                                label="Active"
+                                hint={dragTargetSection === "active" ? activeHint : null}
+                                isDropTarget={dragTargetSection === "active"}
+                                visible={from !== null && previewPinnedCount > 0}
+                              />,
+                            );
+                            break;
+                          case "active-placeholder":
+                            items.push(
+                              <SidebarSectionPlaceholder
+                                key="active-placeholder"
+                                marker="active-placeholder"
+                                label={activeHint ?? "Drop here"}
+                                showHint={from !== null}
+                                isDropTarget={dragTargetSection === "active"}
+                              />,
+                            );
+                            break;
+                          case "snoozed-header":
+                            items.push(
+                              <SidebarSectionHeader
+                                key="snoozed-shelf-header"
+                                marker="snoozed-header"
+                                label={
+                                  snoozedShelfExpanded
+                                    ? "Snoozed"
+                                    : `Snoozed (${snoozedThreads.length})`
                                 }
-                                return (
-                                  <SortablePinnedThreadRow key={threadKey} id={threadKey}>
-                                    {(bag) => renderThreadRow(thread, "pinned", bag)}
-                                  </SortablePinnedThreadRow>
-                                );
-                              })}
-                            </ul>
-                          </SortableContext>
-                        </DndContext>
-                      </li>
-                    ) : null,
-                  ];
-                  if (pinnedThreads.length > 0) {
-                    items.push(
-                      <li
-                        key="pinned-divider"
-                        aria-hidden
-                        data-testid="sidebar-pinned-divider"
-                        className="mx-2.5 my-1.5 h-px list-none bg-sidebar-border/60"
-                      />,
-                    );
-                  }
-                  for (const thread of activeThreads) {
-                    items.push(renderThreadRow(thread, "active"));
-                  }
-                  // Snoozed shelf: between the inbox and Settled — out of the
-                  // way, never gone. The header always renders while anything
-                  // is snoozed (the count is the whole footprint when
-                  // collapsed); rows only when expanded. Vanishes entirely at
-                  // count 0.
-                  if (snoozedThreads.length > 0) {
-                    items.push(
-                      <li
-                        key="snoozed-shelf-header"
-                        data-thread-selection-safe
-                        className="list-none"
-                      >
-                        <button
-                          type="button"
-                          onClick={toggleSnoozedShelf}
-                          aria-expanded={snoozedShelfExpanded}
-                          data-testid="sidebar-snoozed-shelf-toggle"
-                          className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left"
-                        >
-                          <span className="text-xs font-medium text-blue-600 dark:text-blue-400">
-                            {snoozedShelfExpanded
-                              ? "Snoozed"
-                              : `Snoozed (${snoozedThreads.length})`}
-                          </span>
-                          <span className="h-px flex-1 bg-blue-500/20 dark:bg-blue-400/15" />
-                          <ChevronDownIcon
-                            aria-hidden
+                                toggle={{
+                                  expanded: snoozedShelfExpanded,
+                                  onToggle: toggleSnoozedShelf,
+                                }}
+                              />,
+                            );
+                            break;
+                          case "settled-header":
+                            items.push(
+                              <SidebarSectionHeader
+                                key="settled-shelf-header"
+                                marker="settled-header"
+                                label={
+                                  settledShelfExpanded
+                                    ? "Settled"
+                                    : `Settled (${settledThreads.length})`
+                                }
+                                hint={
+                                  dragTargetSection === "settled" && from !== "settled"
+                                    ? "Drop to settle"
+                                    : null
+                                }
+                                isDropTarget={dragTargetSection === "settled"}
+                                toggle={{
+                                  expanded: settledShelfExpanded,
+                                  onToggle: toggleSettledShelf,
+                                }}
+                              />,
+                            );
-                            className={cn(
-                              "size-3 text-blue-600 transition-transform dark:text-blue-400",
-                              snoozedShelfExpanded && "rotate-180",
-                            )}
-                          />
-                        </button>
-                      </li>,
+                            break;
+                          case "settled-placeholder":
+                            items.push(
+                              <SidebarSectionPlaceholder
-                    );
-                    for (const thread of visibleSnoozedThreads) {
-                      items.push(renderThreadRow(thread, "snoozed"));
+                                key="settled-placeholder"
+                                marker="settled-placeholder"
+                                label="Drop to settle"
+                                showHint={from !== null && from !== "settled"}
+                                isDropTarget={dragTargetSection === "settled"}
+                              />,
+                            );
+                            break;
+                        }
+                      }
+                      return items;
+                    })()}
+                    {settledShelfExpanded && hiddenSettledCount > 0 ? (
+                      <li className="list-none">
-                    }
-                  }
-                  if (settledThreads.length > 0) {
-                    items.push(
-                      <li
-                        key="settled-shelf-header"
-                        data-thread-selection-safe
-                        className="list-none"
-                      >
                         <button
                           type="button"
-                          onClick={toggleSettledShelf}
+                          onClick={showMoreSettled}
-                          aria-expanded={settledShelfExpanded}
-                          data-testid="sidebar-settled-shelf-toggle"
-                          className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left"
+                          className="flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm text-sidebar-muted-foreground/55 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
                         >
-                          <span className="text-xs font-medium text-muted-foreground/50">
-                            {settledShelfExpanded
-                              ? "Settled"
-                              : `Settled (${settledThreads.length})`}
-                          </span>
-                          <span className="h-px flex-1 bg-sidebar-border/60" />
-                          <ChevronDownIcon
-                            aria-hidden
-                            className={cn(
-                              "size-3 text-muted-foreground/50 transition-transform",
-                              settledShelfExpanded && "rotate-180",
-                            )}
-                          />
+                          <PlusIcon aria-hidden className="size-4 shrink-0" />
+                          Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more
                         </button>
-                      </li>,
-                    );
-                  }
-                  for (const thread of renderedSettledThreads) {
-                    items.push(renderThreadRow(thread, "settled"));
-                  }
-                  return items;
-                })()}
-                {settledShelfExpanded && hiddenSettledCount > 0 ? (
-                  <li className="list-none">
-                    <button
-                      type="button"
-                      onClick={showMoreSettled}
-                      className="flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm text-sidebar-muted-foreground/55 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
-                    >
-                      <PlusIcon aria-hidden className="size-4 shrink-0" />
-                      Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more
-                    </button>
-                  </li>
+                      </li>
+                    ) : null}
+                  </ul>
+                </SortableContext>
-                ) : null}
+              </DndContext>
-              </ul>
             </TooltipProvider>
           ) : null}
           {!isSearchingThreads &&
```

## apps/web/src/hooks/useThreadActions.ts

Status: modified; +38; −0

```diff
@@ -25,6 +25,7 @@
 import {
   readEnvironmentSupportsPinning,
   readEnvironmentSupportsPinReorder,
+  readEnvironmentSupportsActiveReorder,
   readEnvironmentSupportsSettlement,
   readEnvironmentSupportsSnooze,
   readEnvironmentThreadRefs,
@@ -124,6 +125,18 @@
   }
 }
 
+export class ThreadActiveReorderUnsupportedError extends Schema.TaggedErrorClass<ThreadActiveReorderUnsupportedError>()(
+  "ThreadActiveReorderUnsupportedError",
+  {
+    environmentId: EnvironmentId,
+    threadId: ThreadId,
+  },
+) {
+  override get message(): string {
+    return "Update this environment's server to reorder active threads.";
+  }
+}
+
 export async function requestThreadUnpinConfirmation(input: {
   enabled: boolean;
   title: string;
@@ -185,6 +198,9 @@
   const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, {
     reportFailure: false,
   });
+  const reorderActiveThreadMutation = useAtomCommand(threadEnvironment.reorderActive, {
+    reportFailure: false,
+  });
   const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, {
     reportFailure: false,
   });
@@ -631,6 +647,26 @@
     [reorderPinnedThreadMutation],
   );
 
+  const reorderActiveThread = useCallback(
+    async (target: ScopedThreadRef, orderKey: string) => {
+      if (!readEnvironmentSupportsActiveReorder(target.environmentId)) {
+        return AsyncResult.failure(
+          Cause.fail(
+            new ThreadActiveReorderUnsupportedError({
+              environmentId: target.environmentId,
+              threadId: target.threadId,
+            }),
+          ),
+        );
+      }
+      return reorderActiveThreadMutation({
+        environmentId: target.environmentId,
+        input: { threadId: target.threadId, orderKey },
+      });
+    },
+    [reorderActiveThreadMutation],
+  );
+
   const snoozeThread = useCallback(
     async (target: ScopedThreadRef, snoozedUntil: string) => {
       // Version skew: never send the command to a server that predates it.
@@ -729,6 +765,7 @@
       unpinThread,
       confirmAndUnpinThread,
       reorderPinnedThread,
+      reorderActiveThread,
     }),
     [
       archiveThread,
@@ -737,6 +774,7 @@
       deleteThread,
       pinThread,
       reorderPinnedThread,
+      reorderActiveThread,
       settleThread,
       snoozeThread,
       unarchiveThread,
```

## apps/web/src/lib/threadSort.ts

Status: modified; +0; −1

```diff
@@ -1,5 +1,4 @@
 export {
-  activeThreadAnchorTimestampMs,
   getLatestThreadForProject,
   getThreadSortTimestamp,
   resolveSettledThreadTimestamp,
```

## apps/web/src/state/entities.ts

Status: modified; +7; −0

```diff
@@ -229,6 +229,13 @@
   );
 }
 
+export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean {
+  return (
+    appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
+      .threadActiveReorder === true
+  );
+}
+
 export function readEnvironmentThreadRefs(
   environmentId: EnvironmentId,
 ): ReadonlyArray<ScopedThreadRef> {
```

## docs/user/thread-sidebar.md

Status: modified; +31; −3

```diff
@@ -22,12 +22,40 @@
 
 ## Pin and reorder threads
 
-Pin a thread from its menu to keep it above your active work. Drag pinned threads
+Pin a thread from its menu to keep it above your active work.
-to reorder them on web and desktop, or use **Move up** and **Move down** on mobile.
-The order syncs across devices.
 
 Pinning does not prevent automatic settlement. Settling a thread removes its pin.
 
+On web and desktop, drag a thread between sections to change its state. Drag a thread up into
+the pinned section to pin it at the spot you drop it; drag a pinned thread down into the active
+list to unpin it. Dragging a thread onto the **Settled** header settles it, and dragging a settled
+thread into the active list un-settles it. A snoozed thread can be dragged out of the snoozed
+shelf, which wakes it, but threads cannot be dragged into the shelf because snoozing needs a wake
+time. Dragging a pinned thread out of the pinned section does not ask for unpin confirmation.
+Pinned and active boundary labels appear only while dragging, without moving the rows. The
+destination boundary highlights and the thread shows which section it will land in. When there
+are no pins, drag to the top edge to pin a thread. Drop instructions also appear for empty sections
+and a collapsed settled shelf.
+
+Drag within the pinned or active section to change its order. Other rows slide aside to show the
+spot where the thread will land. Drops into either section keep the position you choose. On
+mobile, open a pinned or active thread's menu and choose **Move up** or **Move down**. The server
+saves the order, so it survives a refresh and appears on your other connected devices.
+
+On web and desktop, the list also animates section changes made with thread actions such as
+**Pin**, **Settle**, and **Snooze**. These transitions respect your system's reduced-motion
+preference. While dragging, rows follow the insertion gap without replaying a second transition
+after the drop.
+
+New threads appear above the active threads you have arranged. Settling clears a thread's active
+position, so using **Un-settle** returns it to the top. Pinning and snoozing preserve its active
+position until you move it again. Thread activity does not change the order. The settled shelf
+continues to use settlement time.
+
+If dragging is unavailable for one environment, update the T3 Code server running in that
+environment. Pinned and active reordering require server support. Threads from older servers keep
+their default order until the server is updated.
+
 ## Settle finished work
 
 Choose **Settle thread** from its menu to move finished work out of the active list
```

## packages/client-runtime/src/state/threadSort.ts

Status: modified; +1; −1

```diff
@@ -107,7 +107,7 @@
  * top instead of sinking back to its creation-order slot. Shared by web and
  * mobile so both render the same order. Malformed timestamps sink to 0.
  */
-export function activeThreadAnchorTimestampMs(thread: {
+function activeThreadAnchorTimestampMs(thread: {
   readonly createdAt: string;
   readonly unsettledAt?: string | null | undefined;
 }): number {
```
