feat(web): drag threads across sections with consistent motion#9731

merged4
sidebar/mobile-active-order
sidebar/drag-sections
openedSep 4, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 3d ago
5 unresolved threads · Jump to file
Selected thread: apps/web/src/components/Sidebar.tsx:2494 — 🟠 **High** `components/Sidebar.tsx:2490` Pinned rows become non-draggable on servers that support pinning and `threadPinReorder` but not `threadSettlement`, so users lose the existing pin-reorder functionality. Remove `supportsSettlement` from this drag eligibility check. ```suggestion if ( capabilities?.threadPinning === true && capabilities.threadPinReorder === true ) { ``` <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> ```text In file @apps/web/src/components/Sidebar.tsx around lines 2490-2494: Pinned rows become non-draggable on servers that support pinning and `threadPinReorder` but not `threadSettlement`, so users lose the existing pin-reorder functionality. Remove `supportsSettlement` from this drag eligibility check. ``` </details> <!-- macroscope-meta: {"kind":"code_review","variant":"f57f0b61c1d8f13d673564c863408287"} -->
0 / 12 viewed12 files changedLine totals unavailable

apps/web/src/components/Sidebar.logic.ts · Earlier revision d68f19b8 · right 95

Original diff
@@ -77,12 +77,256 @@ export function useRetainedValue<T>(key: string | null, value: T | null): T | nu
   return key !== null && retained.current?.key === key ? retained.current.value : null;
 }
 
-// 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) =>
+// 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) =>
   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
+    prefix that cannot collide with `<environmentId>:<threadId>`. */
+const SIDEBAR_MARKER_PREFIX = "marker:";
M
macroscopeappBotSep 5, 2026

🟡 Medium components/Sidebar.logic.ts:95

SIDEBAR_MARKER_PREFIX does not make marker IDs distinct from scoped thread keys: an environment marker with thread ID pinned-header produces the same ID, marker:pinned-header. resolveSidebarDropTarget then resolves the dragged thread as the marker, rejects the drop, and dnd-kit receives duplicate sortable IDs. Use a collision-free encoding for both thread and marker sortable IDs.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/components/Sidebar.logic.ts around line 95:
2
3`SIDEBAR_MARKER_PREFIX` does not make marker IDs distinct from scoped thread keys: an environment `marker` with thread ID `pinned-header` produces the same ID, `marker:pinned-header`. `resolveSidebarDropTarget` then resolves the dragged thread as the marker, rejects the drop, and dnd-kit receives duplicate sortable IDs. Use a collision-free encoding for both thread and marker sortable IDs.
J
Julius MarmingeSep 6, 2026

Fixed in 33e70c44a31. Marker IDs now use a colon-free namespace. Every scoped thread key contains the environment/thread colon separator, so the two sets are disjoint even for environment marker and thread pinned-header. The regression test drags that exact scoped key and verifies unique sortable IDs and the resulting order.

apps/web/src/components/Sidebar.tsx · Earlier revision d68f19b8 · right 2490–2494

Original diff
@@ -2269,17 +2473,46 @@ export default function Sidebar() {
     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 =
-        serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
-      // Snooze outranks settlement and pinning until the thread wakes.
-      if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
+      const supportsSettlement = capabilities?.threadSettlement === true;
+      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 (
+        supportsSettlement &&
+        capabilities?.threadPinning === true &&
+        capabilities.threadPinReorder === true
+      ) {

apps/web/src/components/Sidebar.tsx · right 2490–2494

M
macroscopeappBotSep 5, 2026

🟠 High components/Sidebar.tsx:2490

Pinned rows become non-draggable on servers that support pinning and threadPinReorder but not threadSettlement, so users lose the existing pin-reorder functionality. Remove supportsSettlement from this drag eligibility check.

suggestion
1 if (
2 capabilities?.threadPinning === true &&
3 capabilities.threadPinReorder === true
4 ) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/components/Sidebar.tsx around lines 2490-2494:
2
3Pinned rows become non-draggable on servers that support pinning and `threadPinReorder` but not `threadSettlement`, so users lose the existing pin-reorder functionality. Remove `supportsSettlement` from this drag eligibility check.
J
Julius MarmingeSep 6, 2026

Fixed in 33e70c44a31. Pickup now requires pinning and pin-reorder support without requiring settlement. Both preview and commit planning reject settlement when the source server lacks that capability. The regression test verifies pin reordering remains available while settlement is rejected.

apps/web/src/components/Sidebar.tsx · Earlier revision d2c3f461 · right 3052

Original diff
@@ -2735,77 +2977,122 @@ export default function Sidebar() {
     },
     [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
-  // 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
-  // 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(
+  const listMotionRef = useRef<ReturnType<typeof createSidebarListMotion> | null>(null);
+  const attachListMotionRef = useCallback((node: HTMLUListElement | null) => {
+    listMotionRef.current?.dispose();
+    listMotionRef.current = node === null ? null : createSidebarListMotion(node);
+    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(
     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. */
-    readonly assignedKeys: ReadonlyMap<string, string>;
+  const [dragState, setDragState] = useState<{
+    readonly activeKey: string;
+    readonly activeSection: SidebarSection;
+    readonly occurredAt: 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) =>
-      reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
+    if (optimisticDrop === null) return;
+    const canonicalByKey = new Map(
+      threads.map((thread) => [
+        scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+        thread,
+      ]),
     );
-    const canonicalKeys = canonical.map((thread) =>
-      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;
C
cursor[bot]Sep 4, 2026

Optimistic drop clears on intermediate events

Medium Severity

The optimistic-drop hold is dropped as soon as canonical state leaves both the source and destination sections. A settle from Pinned or Snoozed emits unpin/unsnooze first, so the row can snap into Active (or Settled) before the rest of the command lands, then jump again — and a second drag can start against that half-applied state.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit d2c3f461c5bfbee80ff37ef951516be5e66b88e6. Configure here.

J
Julius MarmingeSep 4, 2026

Verified against d2c3f461c5bfbee80ff37ef951516be5e66b88e6: the server emits thread.settled first, followed by unpin/unsnooze companion events (decider.ts:524).

A pinned thread therefore classifies directly as Settled; the hold remains until pinnedAt is cleared. A snoozed thread stays in Snoozed until unsnooze cleanup, then classifies as Settled. The classification and completion checks cover both paths, so this sequence never passes through Active. No code change needed.

apps/web/src/components/Sidebar.logic.ts · Earlier revision d68f19b8 · right 237

Original diff
@@ -77,12 +77,256 @@ export function useRetainedValue<T>(key: string | null, value: T | null): T | nu
   return key !== null && retained.current?.key === key ? retained.current.value : null;
 }
 
-// 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) =>
+// 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) =>
   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
+    prefix that cannot collide with `<environmentId>:<threadId>`. */
+const SIDEBAR_MARKER_PREFIX = "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. */
+export 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 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;
+  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({
M
macroscopeappBotSep 5, 2026

🟡 Medium components/Sidebar.logic.ts:237

planSidebarThreadDrop can assign an activeOrderKey or pin orderKey already held by a hidden snoozed thread, so waking that thread creates duplicate keys and the persisted order no longer matches the drop. The caller populates activeKeysById and pinnedKeysById from visible rows only; include all threads retaining Active or pinned keys as ordering anchors, or reject generated keys that exist in the complete set.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/components/Sidebar.logic.ts around line 237:
2
3`planSidebarThreadDrop` can assign an `activeOrderKey` or pin `orderKey` already held by a hidden snoozed thread, so waking that thread creates duplicate keys and the persisted order no longer matches the drop. The caller populates `activeKeysById` and `pinnedKeysById` from visible rows only; include all threads retaining Active or pinned keys as ordering anchors, or reject generated keys that exist in the complete set.
J
Julius MarmingeSep 6, 2026

Fixed across the stack. The shared planner reserves keys held by rows outside the displayed order for both midpoint insertion and keyless materialization. Web supplies every thread key, and both mobile entry points do the same. Hidden rows receive no writes. Focused regressions cover Active and pinned lists.

M
macroscopeappBotSep 6, 2026

Confirmed: hidden keys are reserved during generation and hidden rows are not assigned. The focused regressions cover the reported collision.

apps/web/src/components/Sidebar.tsx · Earlier revision d2c3f461 · right 3198

Original diff
@@ -2847,71 +3134,324 @@ export default function Sidebar() {
     [confirmAndUnpinThread],
   );
 
-  const handlePinnedDragEnd = useCallback(
+  const handleThreadDragStart = useCallback(
+    (event: DragStartEvent) => {
+      const activeKey = String(event.active.id);
+      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));
+        return { kind: "thread", key, section };
+      });
+    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);
C
cursor[bot]Sep 4, 2026

Empty Settled section shown when idle

Medium Severity

The Settled header is always mounted whenever any thread exists, including when settledThreads is empty. Idle sidebars now show a Settled heading and a 36px placeholder that previously appeared only with settled rows, or during a drag as a drop hint.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit d2c3f461c5bfbee80ff37ef951516be5e66b88e6. Configure here.

J
Julius MarmingeSep 4, 2026

This is intentional in d2c3f461c5bfbee80ff37ef951516be5e66b88e6: the empty Settled destination keeps its measured geometry so pickup does not shift the list. Settled remains an available shelf and drop destination.

The placeholder's label and border are hidden at idle; its showHint condition becomes true only during an eligible drag. Pinned and Active use zero-height boundary markers with conditional labels, so those boundaries do not appear at idle. No code change needed.