Просмотр исходного кода

refactor(app): replace tab drag handling with dndkit (#33880)

Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Brendan Allan 2 месяцев назад
Родитель
Сommit
929c4aaf29

+ 20 - 0
bun.lock

@@ -31,6 +31,10 @@
       "name": "@opencode-ai/app",
       "version": "1.17.11",
       "dependencies": {
+        "@dnd-kit/abstract": "0.5.0",
+        "@dnd-kit/dom": "0.5.0",
+        "@dnd-kit/helpers": "0.5.0",
+        "@dnd-kit/solid": "0.5.0",
         "@kobalte/core": "catalog:",
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
@@ -1451,6 +1455,20 @@
 
     "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
 
+    "@dnd-kit/abstract": ["@dnd-kit/abstract@0.5.0", "", { "dependencies": { "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA=="],
+
+    "@dnd-kit/collision": ["@dnd-kit/collision@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ=="],
+
+    "@dnd-kit/dom": ["@dnd-kit/dom@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/collision": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw=="],
+
+    "@dnd-kit/geometry": ["@dnd-kit/geometry@0.5.0", "", { "dependencies": { "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A=="],
+
+    "@dnd-kit/helpers": ["@dnd-kit/helpers@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-i4y+51/icSw+OHMr/su19qhnmNhAzh8PnBwXvapFYTd+64oodIyJRiRkB+hhfxAfnur7RYSW8qacDTrXjg2XOg=="],
+
+    "@dnd-kit/solid": ["@dnd-kit/solid@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-IKDqVZICS0jEeUzpJMIIF61w0WA4zisyx9U7K7Skbmkb/kQSDa3lB0cOc0947RwSO+ALoxytRNOuoNfyOIm3lQ=="],
+
+    "@dnd-kit/state": ["@dnd-kit/state@0.5.0", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w=="],
+
     "@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="],
 
     "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
@@ -2287,6 +2305,8 @@
 
     "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
 
+    "@preact/signals-core": ["@preact/signals-core@1.14.3", "", {}, "sha512-m0K3vnbSLC5rHs2ZVfeAMvBtT1zIyq4mxx5OlNncSgMj5Iz6W5Rn3kPrDxAC+iIKmiVe0lSl6U37t5ZkEWoVAw=="],
+
     "@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="],
 
     "@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="],

+ 4 - 0
packages/app/package.json

@@ -44,6 +44,10 @@
     "vite-plugin-solid": "catalog:"
   },
   "dependencies": {
+    "@dnd-kit/abstract": "0.5.0",
+    "@dnd-kit/dom": "0.5.0",
+    "@dnd-kit/helpers": "0.5.0",
+    "@dnd-kit/solid": "0.5.0",
     "@kobalte/core": "catalog:",
     "@opencode-ai/core": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",

+ 0 - 141
packages/app/src/components/titlebar-tab-drag.test.ts

@@ -1,141 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { captureTabDragLayout, insertIndexFromVirtualLayout } from "./titlebar-tab-drag"
-import {
-  canOpenTabRename,
-  captureTabPointerDown,
-  canStartTabDrag,
-  createTabDragPreview,
-  forwardTabRef,
-  isPrimaryPointerPressed,
-  isTabCloseTarget,
-} from "./titlebar-tab-gesture"
-
-describe("titlebar tab drag", () => {
-  const layout = {
-    listLeft: 100,
-    dividerWidth: 13,
-    tabWidthById: new Map([
-      ["a", 40],
-      ["b", 40],
-      ["c", 40],
-      ["d", 40],
-    ]),
-  }
-
-  test("moves across multiple tabs from one pointer update", () => {
-    expect(insertIndexFromVirtualLayout(260, ["a", "b", "c", "d"], "a", 0, layout)).toBe(3)
-    expect(insertIndexFromVirtualLayout(90, ["a", "b", "c", "d"], "d", 3, layout)).toBe(0)
-  })
-
-  test("keeps the current index inside the left hysteresis deadband", () => {
-    expect(insertIndexFromVirtualLayout(146, ["a", "b", "c", "d"], "b", 1, layout)).toBe(1)
-  })
-
-  test("includes slot margins in captured divider width", () => {
-    const list = document.createElement("div")
-    const first = document.createElement("div")
-    const second = document.createElement("div")
-    const firstTab = document.createElement("div")
-    const secondTab = document.createElement("div")
-    first.dataset.titlebarTabSlot = ""
-    first.dataset.tabKey = "a"
-    second.dataset.titlebarTabSlot = ""
-    second.dataset.tabKey = "b"
-    second.style.marginLeft = "6px"
-    firstTab.dataset.titlebarTab = ""
-    secondTab.dataset.titlebarTab = ""
-    first.append(firstTab)
-    second.append(secondTab)
-    list.append(first, second)
-    document.body.append(list)
-    firstTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
-    secondTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
-    second.getBoundingClientRect = () => ({ width: 47 }) as DOMRect
-    list.getBoundingClientRect = () => ({ left: 100 }) as DOMRect
-
-    expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13)
-    list.remove()
-  })
-
-  test("uses the list gap as the divider width", () => {
-    const list = document.createElement("div")
-    const first = document.createElement("div")
-    const second = document.createElement("div")
-    const firstTab = document.createElement("div")
-    const secondTab = document.createElement("div")
-    first.dataset.titlebarTabSlot = ""
-    first.dataset.tabKey = "a"
-    second.dataset.titlebarTabSlot = ""
-    second.dataset.tabKey = "b"
-    firstTab.dataset.titlebarTab = ""
-    secondTab.dataset.titlebarTab = ""
-    first.append(firstTab)
-    second.append(secondTab)
-    list.append(first, second)
-    list.style.columnGap = "13.5px"
-    document.body.append(list)
-
-    expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13.5)
-    list.remove()
-  })
-})
-
-describe("titlebar tab gestures", () => {
-  test("excludes close controls from tab gestures", () => {
-    const close = document.createElement("div")
-    const button = document.createElement("button")
-    const link = document.createElement("a")
-    close.dataset.slot = "tab-close"
-    close.append(button)
-    expect(isTabCloseTarget(close)).toBe(true)
-    expect(isTabCloseTarget(button)).toBe(true)
-    expect(isTabCloseTarget(link)).toBe(false)
-  })
-
-  test("forwards component refs", () => {
-    const element = document.createElement("div")
-    let received: HTMLDivElement | undefined
-    forwardTabRef((value) => (received = value), element)
-    expect(received).toBe(element)
-  })
-
-  test("does not reopen rename while a save is pending", () => {
-    expect(canOpenTabRename(false, false, false)).toBe(true)
-    expect(canOpenTabRename(false, false, true)).toBe(false)
-  })
-
-  test("keeps the rendered tab content in the drag preview", () => {
-    const tab = document.createElement("div")
-    tab.innerHTML = '<span data-slot="project-avatar-slot"></span><span data-slot="tab-title">Session</span>'
-    const preview = createTabDragPreview(tab)
-    expect(preview.querySelector('[data-slot="project-avatar-slot"]')).not.toBeNull()
-    expect(preview.querySelector('[data-slot="tab-title"]')?.textContent).toBe("Session")
-  })
-
-  test("captures the grab offset before navigation scrolls the tab", () => {
-    const tab = document.createElement("div")
-    tab.getBoundingClientRect = () => ({ left: 80, top: 10, width: 120 }) as DOMRect
-
-    expect(captureTabPointerDown(tab, 100, 20)).toEqual({
-      startX: 100,
-      startY: 20,
-      grabOffsetX: 20,
-      grabOffsetY: 10,
-      width: 120,
-      element: tab,
-    })
-  })
-
-  test("detects when the primary pointer button was released outside the window", () => {
-    expect(isPrimaryPointerPressed(1)).toBe(true)
-    expect(isPrimaryPointerPressed(3)).toBe(true)
-    expect(isPrimaryPointerPressed(0)).toBe(false)
-    expect(isPrimaryPointerPressed(2)).toBe(false)
-  })
-
-  test("preserves native panning for touch pointers", () => {
-    expect(canStartTabDrag("mouse")).toBe(true)
-    expect(canStartTabDrag("pen")).toBe(true)
-    expect(canStartTabDrag("touch")).toBe(false)
-  })
-})

+ 0 - 137
packages/app/src/components/titlebar-tab-drag.ts

@@ -1,137 +0,0 @@
-export type TabDragLayout = {
-  tabWidthById: Map<string, number>
-  dividerWidth: number
-  listLeft: number
-}
-
-export const ACTIVATION_DISTANCE = 4
-export const HYSTERESIS_DEADBAND = 8
-export const AUTOSCROLL_EDGE = 24
-export const AUTOSCROLL_MAX_SPEED = 8
-export const FLOATER_OVERSHOOT_MAX = 8
-
-export function pointerDistance(x1: number, y1: number, x2: number, y2: number) {
-  const dx = x2 - x1
-  const dy = y2 - y1
-  return Math.sqrt(dx * dx + dy * dy)
-}
-
-export function captureTabDragLayout(list: HTMLElement, order: string[]) {
-  const tabWidthById = new Map<string, number>()
-  const slots = list.querySelectorAll<HTMLElement>("[data-titlebar-tab-slot]")
-  for (const slot of slots) {
-    const id = slot.dataset.tabKey
-    if (!id) continue
-    const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
-    if (!tab) continue
-    tabWidthById.set(id, tab.getBoundingClientRect().width)
-  }
-
-  let dividerWidth = 0
-  if (order.length >= 2) {
-    const gap = Number.parseFloat(getComputedStyle(list).columnGap) || 0
-    const secondId = order[1]
-    for (const slot of slots) {
-      if (slot.dataset.tabKey !== secondId) continue
-      const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
-      if (!tab) break
-      const style = getComputedStyle(slot)
-      dividerWidth =
-        gap ||
-        slot.getBoundingClientRect().width -
-          tab.getBoundingClientRect().width +
-          (Number.parseFloat(style.marginLeft) || 0) +
-          (Number.parseFloat(style.marginRight) || 0)
-      break
-    }
-  }
-
-  return {
-    tabWidthById,
-    dividerWidth,
-    listLeft: list.getBoundingClientRect().left,
-  }
-}
-
-export function syncLayoutScroll(list: HTMLElement, layout: TabDragLayout) {
-  layout.listLeft = list.getBoundingClientRect().left
-}
-
-function slotWidthAt(order: readonly string[], index: number, layout: TabDragLayout) {
-  const id = order[index]
-  if (!id) return 0
-  const tabWidth = layout.tabWidthById.get(id) ?? 0
-  return index === 0 ? tabWidth : layout.dividerWidth + tabWidth
-}
-
-function slotLeft(order: readonly string[], index: number, layout: TabDragLayout) {
-  let left = layout.listLeft
-  for (let i = 0; i < index; i++) {
-    left += slotWidthAt(order, i, layout)
-  }
-  return left
-}
-
-export function insertIndexFromVirtualLayout(
-  pointerX: number,
-  order: readonly string[],
-  draggedId: string,
-  currentIndex: number,
-  layout: TabDragLayout,
-  deadband = HYSTERESIS_DEADBAND,
-) {
-  if (order.length === 0) return 0
-
-  const others = order.filter((id) => id !== draggedId)
-  let target = currentIndex
-
-  while (target > 0 && pointerX < slotLeft(others, target, layout) - deadband) target--
-  while (target < order.length - 1 && pointerX >= slotLeft(others, target + 1, layout)) target++
-
-  return target
-}
-
-export function movePlaceholder(order: readonly string[], draggedId: string, toIndex: number) {
-  const fromIndex = order.indexOf(draggedId)
-  if (fromIndex === -1 || fromIndex === toIndex) return [...order]
-  const next = [...order]
-  next.splice(toIndex, 0, ...next.splice(fromIndex, 1))
-  return next
-}
-
-export function draftOrderChanged(initial: readonly string[], final: readonly string[]) {
-  if (initial.length === 0 || final.length === 0 || initial.length !== final.length) return false
-  return final.some((key, index) => key !== initial[index])
-}
-
-function easeOvershoot(overshoot: number) {
-  return (FLOATER_OVERSHOOT_MAX * overshoot) / (overshoot + FLOATER_OVERSHOOT_MAX)
-}
-
-export function clampFloaterLeft(left: number, width: number, stripLeft: number, stripRight: number) {
-  const stripWidth = stripRight - stripLeft
-  if (width >= stripWidth) return stripLeft
-
-  const maxLeft = stripRight - width
-  if (left > maxLeft) return maxLeft + easeOvershoot(left - maxLeft)
-  if (left < stripLeft) return stripLeft - easeOvershoot(stripLeft - left)
-
-  return left
-}
-
-export function autoscrollSpeed(pointerX: number, containerLeft: number, containerRight: number) {
-  const leftEdge = containerLeft + AUTOSCROLL_EDGE
-  const rightEdge = containerRight - AUTOSCROLL_EDGE
-
-  if (pointerX < leftEdge) {
-    const depth = (leftEdge - pointerX) / AUTOSCROLL_EDGE
-    return -Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
-  }
-
-  if (pointerX > rightEdge) {
-    const depth = (pointerX - rightEdge) / AUTOSCROLL_EDGE
-    return Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
-  }
-
-  return 0
-}

+ 33 - 0
packages/app/src/components/titlebar-tab-gesture.test.ts

@@ -0,0 +1,33 @@
+import { describe, expect, test } from "bun:test"
+import { canOpenTabRename, canStartTabDrag, forwardTabRef, isTabCloseTarget } from "./titlebar-tab-gesture"
+
+describe("titlebar tab gestures", () => {
+  test("excludes close controls from tab gestures", () => {
+    const close = document.createElement("div")
+    const button = document.createElement("button")
+    const link = document.createElement("a")
+    close.dataset.slot = "tab-close"
+    close.append(button)
+    expect(isTabCloseTarget(close)).toBe(true)
+    expect(isTabCloseTarget(button)).toBe(true)
+    expect(isTabCloseTarget(link)).toBe(false)
+  })
+
+  test("forwards component refs", () => {
+    const element = document.createElement("div")
+    let received: HTMLDivElement | undefined
+    forwardTabRef((value) => (received = value), element)
+    expect(received).toBe(element)
+  })
+
+  test("does not reopen rename while a save is pending", () => {
+    expect(canOpenTabRename(false, false, false)).toBe(true)
+    expect(canOpenTabRename(false, false, true)).toBe(false)
+  })
+
+  test("preserves native panning for touch pointers", () => {
+    expect(canStartTabDrag("mouse")).toBe(true)
+    expect(canStartTabDrag("pen")).toBe(true)
+    expect(canStartTabDrag("touch")).toBe(false)
+  })
+})

+ 0 - 20
packages/app/src/components/titlebar-tab-gesture.ts

@@ -8,22 +8,6 @@ export function canStartTabDrag(pointerType: string) {
   return pointerType !== "touch"
 }
 
-export function isPrimaryPointerPressed(buttons: number) {
-  return (buttons & 1) !== 0
-}
-
-export function captureTabPointerDown(element: HTMLDivElement, clientX: number, clientY: number) {
-  const rect = element.getBoundingClientRect()
-  return {
-    startX: clientX,
-    startY: clientY,
-    grabOffsetX: clientX - rect.left,
-    grabOffsetY: clientY - rect.top,
-    width: rect.width,
-    element,
-  }
-}
-
 export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
   if (typeof ref === "function") ref(element)
 }
@@ -31,7 +15,3 @@ export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTM
 export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
   return !dragging && !editing && !committing
 }
-
-export function createTabDragPreview(element: HTMLDivElement) {
-  return element.cloneNode(true) as HTMLDivElement
-}

+ 4 - 0
packages/app/src/components/titlebar-tab-nav.css

@@ -9,6 +9,10 @@
   justify-content: center;
 }
 
+[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
+  display: none;
+}
+
 [data-titlebar-tab-list] {
   gap: 13.5px;
 }

+ 142 - 159
packages/app/src/components/titlebar-tab-nav.tsx

@@ -29,9 +29,6 @@ export function TabNavItem(props: {
   dragging?: boolean
   pressed?: boolean
   hidden?: boolean
-  tabKey: string
-  dragActive: boolean
-  onPointerDown: (event: PointerEvent) => void
 }) {
   const language = useLanguage()
   const [editing, setEditing] = createSignal(false)
@@ -173,112 +170,108 @@ export function TabNavItem(props: {
 
   return (
     <div
-      data-titlebar-tab-slot
-      data-tab-key={props.tabKey}
-      class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
-      classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
-      onPointerDown={props.onPointerDown}
+      ref={(el) => {
+        tabRoot = el
+        forwardTabRef(props.ref, el)
+      }}
+      data-titlebar-tab
+      data-slot="titlebar-tab-item"
+      data-title-overflow={titleOverflowing()}
+      data-editing={editing()}
+      class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
+      classList={{ invisible: props.hidden }}
+      data-active={props.active}
+      data-dragging={props.dragging}
+      data-pressed={props.pressed}
+      onMouseDown={(event) => {
+        if (event.button !== 1) return
+        closeTab(event)
+      }}
     >
-      <div
-        ref={(el) => {
-          tabRoot = el
-          forwardTabRef(props.ref, el)
-        }}
-        data-titlebar-tab
-        data-slot="titlebar-tab-item"
-        data-title-overflow={titleOverflowing()}
-        data-editing={editing()}
-        class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
-        data-active={props.active}
-        data-dragging={props.dragging}
-        data-pressed={props.pressed}
-        onMouseDown={(event) => {
-          if (event.button !== 1) return
-          closeTab(event)
-        }}
-      >
-        <Show when={props.session()}>
-          {(session) => {
-            return (
-              <a
-                data-slot="tab-link"
-                data-titlebar-tab-link
-                href={props.href}
-                draggable={false}
-                onDragStart={(event) => {
+      <Show when={props.session()}>
+        {(session) => {
+          return (
+            <a
+              data-slot="tab-link"
+              data-titlebar-tab-link
+              href={props.href}
+              draggable={false}
+              onDragStart={(event) => {
+                event.preventDefault()
+                event.stopPropagation()
+              }}
+              onClick={(event) => {
+                event.preventDefault()
+                if (editing()) return
+                if (props.suppressNavigation?.()) return
+                props.onNavigate()
+              }}
+              class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
+            >
+              <span data-slot="project-avatar-slot">
+                <SessionTabAvatar
+                  project={project()}
+                  directory={session().directory}
+                  sessionId={session().id}
+                  activeServer={props.activeServer}
+                />
+              </span>
+              <span
+                ref={(el) => {
+                  titleEl = el
+                  titleEl.textContent = session().title
+                }}
+                data-slot="tab-title"
+                data-titlebar-tab-title
+                class="min-w-0 flex-1 outline-none leading-4"
+                classList={{
+                  "overflow-hidden text-clip whitespace-nowrap": !editing(),
+                  "select-text": editing(),
+                }}
+                contenteditable={editing() ? true : undefined}
+                onDblClick={openRename}
+                onKeyDown={(event) => {
+                  event.stopPropagation()
+                  if (event.key === "Enter") {
+                    event.preventDefault()
+                    void closeRename(true)
+                    return
+                  }
+                  if (event.key !== "Escape") return
                   event.preventDefault()
+                  titleEl.textContent = session().title
+                  void closeRename(false)
+                }}
+                onBlur={() => void closeRename(true)}
+                onPointerDown={(event) => {
+                  if (!editing()) return
                   event.stopPropagation()
                 }}
                 onClick={(event) => {
+                  if (!editing()) return
                   event.preventDefault()
-                  if (editing()) return
-                  if (props.suppressNavigation?.()) return
-                  props.onNavigate()
                 }}
-                class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
-              >
-                <span data-slot="project-avatar-slot">
-                  <SessionTabAvatar
-                    project={project()}
-                    directory={session().directory}
-                    sessionId={session().id}
-                    activeServer={props.activeServer}
-                  />
-                </span>
-                <span
-                  ref={(el) => {
-                    titleEl = el
-                    titleEl.textContent = session().title
-                  }}
-                  data-slot="tab-title"
-                  data-titlebar-tab-title
-                  class="min-w-0 flex-1 outline-none leading-4"
-                  classList={{
-                    "overflow-hidden text-clip whitespace-nowrap": !editing(),
-                    "select-text": editing(),
-                  }}
-                  contenteditable={editing() ? true : undefined}
-                  onDblClick={openRename}
-                  onKeyDown={(event) => {
-                    event.stopPropagation()
-                    if (event.key === "Enter") {
-                      event.preventDefault()
-                      void closeRename(true)
-                      return
-                    }
-                    if (event.key !== "Escape") return
-                    event.preventDefault()
-                    titleEl.textContent = session().title
-                    void closeRename(false)
-                  }}
-                  onBlur={() => void closeRename(true)}
-                  onPointerDown={(event) => {
-                    if (!editing()) return
-                    event.stopPropagation()
-                  }}
-                  onClick={(event) => {
-                    if (!editing()) return
-                    event.preventDefault()
-                  }}
-                />
-              </a>
-            )
-          }}
-        </Show>
+              />
+            </a>
+          )
+        }}
+      </Show>
 
-        <div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
-          <IconButtonV2
-            size="small"
-            variant="ghost-muted"
-            class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
-            onPointerDown={(event) => {
-              event.preventDefault()
-              event.stopPropagation()
-            }}
-            onClick={closeTab}
-            icon={<IconV2 name="xmark-small" />}
-          />
-        </div>
+      <div
+        data-slot="tab-close"
+        class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]"
+      >
+        <IconButtonV2
+          size="small"
+          variant="ghost-muted"
+          class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
+          onPointerDown={(event) => {
+            event.preventDefault()
+            event.stopPropagation()
+          }}
+          onClick={closeTab}
+          icon={<IconV2 name="xmark-small" />}
+        />
       </div>
     </div>
   )
@@ -295,9 +288,6 @@ export function DraftTabItem(props: {
   dragging?: boolean
   pressed?: boolean
   hidden?: boolean
-  tabKey: string
-  dragActive: boolean
-  onPointerDown: (event: PointerEvent) => void
 }) {
   const closeTab = (event: MouseEvent) => {
     event.preventDefault()
@@ -306,69 +296,62 @@ export function DraftTabItem(props: {
   }
   return (
     <div
-      data-titlebar-tab-slot
-      data-tab-key={props.tabKey}
-      class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
-      classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
-      onPointerDown={props.onPointerDown}
+      ref={(el) => forwardTabRef(props.ref, el)}
+      data-titlebar-tab
+      data-slot="titlebar-tab-item"
+      data-active={props.active}
+      data-dragging={props.dragging}
+      data-pressed={props.pressed}
+      class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
+      classList={{ invisible: props.hidden }}
+      onMouseDown={(event) => {
+        if (event.button !== 1) return
+        closeTab(event)
+      }}
     >
-      <div
-        ref={(el) => forwardTabRef(props.ref, el)}
-        data-titlebar-tab
-        data-slot="titlebar-tab-item"
-        data-active={props.active}
-        data-dragging={props.dragging}
-        data-pressed={props.pressed}
-        class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
-        onMouseDown={(event) => {
-          if (event.button !== 1) return
-          closeTab(event)
+      <a
+        data-slot="tab-link"
+        data-titlebar-tab-link
+        href={props.href}
+        draggable={false}
+        onDragStart={(event) => {
+          event.preventDefault()
+          event.stopPropagation()
+        }}
+        onClick={(event) => {
+          event.preventDefault()
+          if (props.suppressNavigation?.()) return
+          props.onNavigate()
         }}
+        class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
       >
-        <a
-          data-slot="tab-link"
-          data-titlebar-tab-link
-          href={props.href}
-          draggable={false}
-          onDragStart={(event) => {
+        <span class="flex size-4 shrink-0 items-center justify-center">
+          <IconV2 name="edit" />
+        </span>
+        <span
+          data-titlebar-tab-title
+          class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
+        >
+          {props.title}
+        </span>
+      </a>
+      <div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
+        <IconButtonV2
+          size="small"
+          variant="ghost-muted"
+          onPointerDown={(event) => {
             event.preventDefault()
             event.stopPropagation()
           }}
-          onClick={(event) => {
+          onMouseDown={(event) => {
             event.preventDefault()
-            if (props.suppressNavigation?.()) return
-            props.onNavigate()
+            event.stopPropagation()
           }}
-          class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
-        >
-          <span class="flex size-4 shrink-0 items-center justify-center">
-            <IconV2 name="edit" />
-          </span>
-          <span
-            data-titlebar-tab-title
-            class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
-          >
-            {props.title}
-          </span>
-        </a>
-        <div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
-          <IconButtonV2
-            size="small"
-            variant="ghost-muted"
-            onPointerDown={(event) => {
-              event.preventDefault()
-              event.stopPropagation()
-            }}
-            onMouseDown={(event) => {
-              event.preventDefault()
-              event.stopPropagation()
-            }}
-            class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
-            onClick={closeTab}
-            icon={<IconV2 name="xmark-small" />}
-            aria-label="Close tab"
-          />
-        </div>
+          class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
+          onClick={closeTab}
+          icon={<IconV2 name="xmark-small" />}
+          aria-label="Close tab"
+        />
       </div>
     </div>
   )

+ 159 - 392
packages/app/src/components/titlebar-tab-strip.tsx

@@ -1,18 +1,11 @@
-import {
-  createEffect,
-  createMemo,
-  createResource,
-  createRoot,
-  createSignal,
-  For,
-  onCleanup,
-  onMount,
-  Show,
-} from "solid-js"
-import { Portal } from "solid-js/web"
-import { createStore } from "solid-js/store"
-import { makeEventListener } from "@solid-primitives/event-listener"
+import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
+import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
+import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
+import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
+import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
+import { RestrictToElement } from "@dnd-kit/dom/modifiers"
+import { arrayMove } from "@dnd-kit/helpers"
 import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
 import { ServerConnection } from "@/context/server"
 import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
@@ -22,42 +15,30 @@ import { useCommand } from "@/context/command"
 import { useTabs } from "@/context/tabs"
 import { createTabPromptState } from "@/context/prompt"
 import { base64Encode } from "@opencode-ai/core/util/encode"
-import {
-  captureTabPointerDown,
-  canStartTabDrag,
-  createTabDragPreview,
-  isPrimaryPointerPressed,
-  isTabCloseTarget,
-} from "./titlebar-tab-gesture"
-import {
-  ACTIVATION_DISTANCE,
-  autoscrollSpeed,
-  captureTabDragLayout,
-  clampFloaterLeft,
-  draftOrderChanged,
-  insertIndexFromVirtualLayout,
-  movePlaceholder,
-  pointerDistance,
-  syncLayoutScroll,
-  type TabDragLayout,
-} from "@/components/titlebar-tab-drag"
+import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
+
+const sortableTransition = { duration: 0 }
 
 function SessionTabSlot(props: {
   tab: SessionTab
   id: string
+  index: () => number
   active: () => boolean
   activeServerKey: ServerConnection.Key
   forceTruncate: boolean
-  dragActive: boolean
-  dragged: () => boolean
-  pressed: () => boolean
   serverCtx: () => ServerCtx | undefined
-  suppressNavigation: () => boolean
-  onPointerDown: (event: PointerEvent) => void
   onNavigate: (element: HTMLDivElement) => void
   onClose: () => void
 }) {
   const tabs = useTabs()
+  const sortable = useSortable({
+    get id() {
+      return props.id
+    },
+    get index() {
+      return props.index()
+    },
+  })
   let ref!: HTMLDivElement
   const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
   const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
@@ -100,33 +81,79 @@ function SessionTabSlot(props: {
   })
 
   return (
-    <TabNavItem
-      tabKey={props.id}
-      dragActive={props.dragActive}
-      onPointerDown={props.onPointerDown}
-      ref={ref}
-      href={tabHref(props.tab)}
-      server={props.tab.server}
-      session={session}
-      onTitleChange={(title) => {
-        const value = session()
-        const ctx = props.serverCtx()
-        if (value && ctx) ctx.sync.session.remember({ ...value, title })
-      }}
-      onTitleChangeFailed={(title) => {
-        const value = session()
-        const ctx = props.serverCtx()
-        if (value && ctx) ctx.sync.session.remember({ ...value, title })
-      }}
-      onNavigate={() => props.onNavigate(ref)}
-      onClose={props.onClose}
-      active={props.active()}
-      activeServer={props.tab.server === props.activeServerKey}
-      forceTruncate={props.forceTruncate}
-      suppressNavigation={props.suppressNavigation}
-      pressed={props.pressed()}
-      hidden={props.dragged() || !session()}
-    />
+    <div
+      ref={sortable.ref}
+      data-titlebar-tab-slot
+      data-tab-key={props.id}
+      class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
+      classList={{ hidden: !session() }}
+    >
+      <TabNavItem
+        ref={(el) => {
+          ref = el
+        }}
+        href={tabHref(props.tab)}
+        server={props.tab.server}
+        session={session}
+        onTitleChange={(title) => {
+          const value = session()
+          const ctx = props.serverCtx()
+          if (value && ctx) ctx.sync.session.remember({ ...value, title })
+        }}
+        onTitleChangeFailed={(title) => {
+          const value = session()
+          const ctx = props.serverCtx()
+          if (value && ctx) ctx.sync.session.remember({ ...value, title })
+        }}
+        onNavigate={() => props.onNavigate(ref)}
+        onClose={props.onClose}
+        active={props.active()}
+        activeServer={props.tab.server === props.activeServerKey}
+        forceTruncate={props.forceTruncate}
+        dragging={sortable.isDragSource()}
+      />
+    </div>
+  )
+}
+
+function DraftTabSlot(props: {
+  tab: Extract<Tab, { type: "draft" }>
+  id: string
+  index: () => number
+  active: () => boolean
+  title: string
+  onNavigate: (element: HTMLDivElement) => void
+  onClose: () => void
+}) {
+  const sortable = useSortable({
+    get id() {
+      return props.id
+    },
+    get index() {
+      return props.index()
+    },
+  })
+  let ref!: HTMLDivElement
+
+  return (
+    <div
+      ref={sortable.ref}
+      data-titlebar-tab-slot
+      data-tab-key={props.id}
+      class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
+    >
+      <DraftTabItem
+        ref={(el) => {
+          ref = el
+        }}
+        href={tabHref(props.tab)}
+        title={props.title}
+        onNavigate={() => props.onNavigate(ref)}
+        onClose={props.onClose}
+        active={props.active()}
+        dragging={sortable.isDragSource()}
+      />
+    </div>
   )
 }
 
@@ -142,52 +169,12 @@ export function TitlebarTabStrip(props: {
 }) {
   const global = useGlobal()
   const language = useLanguage()
-  const [drag, setDrag] = createStore({
-    active: false,
-    draggedId: undefined as string | undefined,
-    placeholderIndex: 0,
-    draftOrder: [] as string[],
-    initialOrder: [] as string[],
-    draggedWidth: 0,
-    pointerX: 0,
-    grabOffsetX: 0,
-    floaterTop: 0,
-  })
-
-  const [gesture, setGesture] = createStore({
-    pending: undefined as
-      | {
-          id: string
-          startX: number
-          startY: number
-          grabOffsetX: number
-          grabOffsetY: number
-          pointerId: number
-          width: number
-          element: HTMLDivElement
-        }
-      | undefined,
-  })
-
-  const [suppressNavigation, setSuppressNavigation] = createSignal(false)
-  const [pressedId, setPressedId] = createSignal<string | undefined>()
-  const [stripScrollLeft, setStripScrollLeft] = createSignal(0)
   let scrollRef!: HTMLDivElement
   let listRef!: HTMLDivElement
-  let dragLayout: TabDragLayout | undefined
-  let dragPointerId: number | undefined
-  let autoscrollFrame: number | undefined
   let resizeFrame: number | undefined
-  let dragPreview: HTMLDivElement | undefined
 
   const tabIds = () => props.tabs.map(tabKey)
 
-  const displayTabs = createMemo(() => {
-    if (!drag.active || drag.draftOrder.length === 0) return props.tabs
-    const byKey = new Map(props.tabs.map((tab) => [tabKey(tab), tab]))
-    return drag.draftOrder.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab)
-  })
-
   function refreshOverflow() {
     if (!scrollRef) return
     props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
@@ -200,220 +187,14 @@ export function TitlebarTabStrip(props: {
       resizeFrame = requestAnimationFrame(() => {
         resizeFrame = undefined
         refreshOverflow()
-        if (!drag.active || !listRef) return
-        dragLayout = captureTabDragLayout(listRef, drag.draftOrder)
-        updateInsertIndex()
       })
     },
   )
 
-  function syncScroll() {
-    if (!scrollRef || !listRef || !dragLayout) return
-    syncLayoutScroll(listRef, dragLayout)
-    setStripScrollLeft(scrollRef.scrollLeft)
-    updateInsertIndex()
-  }
-
-  function stopAutoscroll() {
-    if (autoscrollFrame === undefined) return
-    cancelAnimationFrame(autoscrollFrame)
-    autoscrollFrame = undefined
-  }
-
-  function tickAutoscroll() {
-    if (!drag.active || !scrollRef) return
-
-    const strip = scrollRef.getBoundingClientRect()
-    const speed = autoscrollSpeed(drag.pointerX, strip.left, strip.right)
-
-    if (speed !== 0) {
-      scrollRef.scrollLeft += speed
-      syncScroll()
-    }
-
-    autoscrollFrame = requestAnimationFrame(tickAutoscroll)
-  }
-
-  function startAutoscroll() {
-    stopAutoscroll()
-    autoscrollFrame = requestAnimationFrame(tickAutoscroll)
-  }
-
-  function applyPlaceholderIndex(nextIndex: number) {
-    const id = drag.draggedId
-    if (!id) return
-    const next = movePlaceholder(drag.draftOrder, id, nextIndex)
-    setDrag({
-      draftOrder: next,
-      placeholderIndex: nextIndex,
-    })
-  }
-
-  function updateInsertIndex() {
-    if (!drag.active || !dragLayout) return
-    const draggedId = drag.draggedId
-    if (!draggedId) return
-    const nextIndex = insertIndexFromVirtualLayout(
-      drag.pointerX,
-      drag.draftOrder,
-      draggedId,
-      drag.placeholderIndex,
-      dragLayout,
-    )
-    if (nextIndex === drag.placeholderIndex) return
-    applyPlaceholderIndex(nextIndex)
-  }
-
-  function startDrag(id: string) {
-    const order = tabIds()
-    const index = order.indexOf(id)
-    const pending = gesture.pending
-    if (index === -1 || !pending || !listRef || !scrollRef) return
-
-    dragLayout = captureTabDragLayout(listRef, order)
-    dragPreview = createTabDragPreview(pending.element)
-    dragPointerId = pending.pointerId
-    setGesture("pending", undefined)
-
-    setDrag({
-      active: true,
-      draggedId: id,
-      placeholderIndex: index,
-      draftOrder: order,
-      initialOrder: order,
-      draggedWidth: pending.width,
-      pointerX: pending.startX,
-      grabOffsetX: pending.grabOffsetX,
-      floaterTop: pending.startY - pending.grabOffsetY,
-    })
-    setPressedId(undefined)
-    setStripScrollLeft(scrollRef.scrollLeft)
-    startAutoscroll()
-  }
-
-  function endDrag(commit: boolean) {
-    const initial = drag.initialOrder
-    const final = drag.draftOrder
-    const moved = drag.active
-
-    if (commit && moved && draftOrderChanged(initial, final)) {
-      props.onReorder(final)
-    }
-
-    if (moved) setSuppressNavigation(true)
-
-    setDrag({
-      active: false,
-      draggedId: undefined,
-      placeholderIndex: 0,
-      draftOrder: [],
-      initialOrder: [],
-      draggedWidth: 0,
-      pointerX: 0,
-      grabOffsetX: 0,
-      floaterTop: 0,
-    })
-
-    dragLayout = undefined
-    dragPreview = undefined
-    dragPointerId = undefined
-    setGesture("pending", undefined)
-    setPressedId(undefined)
-    stopAutoscroll()
-    refreshOverflow()
-    requestAnimationFrame(() => setSuppressNavigation(false))
-  }
-
-  function onPointerDown(id: string, event: PointerEvent) {
-    if (event.button !== 0 || drag.active) return
-    if (!canStartTabDrag(event.pointerType)) return
-    if (isTabCloseTarget(event.target)) return
-    const target = event.currentTarget as HTMLDivElement
-    const tabEl = target.matches("[data-titlebar-tab]")
-      ? target
-      : target.querySelector<HTMLDivElement>("[data-titlebar-tab]")
-    if (!tabEl) return
-    if (!tabEl.querySelector('[data-slot="tab-link"]')) return
-    const tab = props.tabs.find((item) => tabKey(item) === id)
-    if (!tab) return
-    const pointer = captureTabPointerDown(tabEl, event.clientX, event.clientY)
-    setSuppressNavigation(true)
-    props.onNavigate(tab, tabEl)
-    setPressedId(id)
-    setGesture("pending", {
-      id,
-      pointerId: event.pointerId,
-      ...pointer,
-    })
-  }
-
-  function onPointerMove(event: PointerEvent) {
-    const pending = gesture.pending
-    if (pending && event.pointerId !== pending.pointerId) return
-    if (drag.active && dragPointerId !== undefined && event.pointerId !== dragPointerId) return
-    if (!isPrimaryPointerPressed(event.buttons)) {
-      if (drag.active) endDrag(true)
-      if (pending) {
-        setGesture("pending", undefined)
-        setPressedId(undefined)
-        requestAnimationFrame(() => setSuppressNavigation(false))
-      }
-      return
-    }
-
-    if (pending && !drag.active) {
-      if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return
-      startDrag(pending.id)
-    }
-
-    if (!drag.active) return
-
-    setDrag("pointerX", event.clientX)
-    syncScroll()
-  }
-
-  function onPointerUp(event: PointerEvent) {
-    if (drag.active) {
-      if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
-      setDrag("pointerX", event.clientX)
-      syncScroll()
-      endDrag(true)
-      return
-    }
-
-    const pending = gesture.pending
-    if (pending && event.pointerId !== pending.pointerId) return
-
-    setGesture("pending", undefined)
-    setPressedId(undefined)
-    requestAnimationFrame(() => setSuppressNavigation(false))
-  }
-
-  function onPointerCancel(event: PointerEvent) {
-    if (drag.active) {
-      if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
-      endDrag(false)
-      return
-    }
-
-    if (!gesture.pending) return
-    if (gesture.pending.pointerId !== event.pointerId) return
-    setGesture("pending", undefined)
-    setPressedId(undefined)
-    requestAnimationFrame(() => setSuppressNavigation(false))
-  }
-
   onMount(() => {
-    const cleanups = [
-      makeEventListener(window, "pointermove", onPointerMove),
-      makeEventListener(window, "pointerup", onPointerUp),
-      makeEventListener(window, "pointercancel", onPointerCancel),
-    ]
     refreshOverflow()
-    onCleanup(() => cleanups.forEach((cleanup) => cleanup()))
   })
 
-  onCleanup(stopAutoscroll)
   onCleanup(() => {
     if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
   })
@@ -424,50 +205,54 @@ export function TitlebarTabStrip(props: {
     refreshOverflow()
   })
 
-  createEffect(() => {
-    if (!drag.active || !scrollRef) return
-    onCleanup(makeEventListener(scrollRef, "scroll", syncScroll))
-  })
-
-  const floaterStyle = () => {
-    stripScrollLeft()
-    const strip = scrollRef?.getBoundingClientRect()
-    const left = strip
-      ? clampFloaterLeft(drag.pointerX - drag.grabOffsetX, drag.draggedWidth, strip.left, strip.right)
-      : drag.pointerX - drag.grabOffsetX
-
-    return {
-      position: "fixed" as const,
-      top: `${drag.floaterTop}px`,
-      left: `${left}px`,
-      width: `${drag.draggedWidth}px`,
-      "z-index": "10000",
-      "pointer-events": "none" as const,
-    }
-  }
-
-  const draggedTab = createMemo(() => {
-    const id = drag.draggedId
-    if (!id) return
-    return props.tabs.find((tab) => tabKey(tab) === id)
-  })
-
   return (
-    <>
-      <div data-slot="titlebar-tabs" class="relative min-w-0">
-        <div
-          data-slot="titlebar-tabs-scroll"
-          class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
-          ref={scrollRef}
+    <div data-slot="titlebar-tabs" class="relative min-w-0">
+      <div
+        data-slot="titlebar-tabs-scroll"
+        class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
+        ref={scrollRef}
+      >
+        <DragDropProvider
+          sensors={[
+            PointerSensor.configure({
+              activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
+              preventActivation: (event) =>
+                !canStartTabDrag(event.pointerType) ||
+                isTabCloseTarget(event.target) ||
+                (event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')),
+            }),
+          ]}
+          modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => listRef })]}
+          plugins={(defaults) => [
+            ...defaults.filter((plugin) => plugin !== Accessibility),
+            AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
+            Feedback.configure({ dropAnimation: null }),
+          ]}
+          onDragStart={(event) => {
+            const source = event.operation.source
+            if (!source) return
+            const tab = props.tabs.find((item) => tabKey(item) === source.id.toString())
+            if (!tab) return
+            const tabEl = source.element?.querySelector<HTMLDivElement>("[data-titlebar-tab]")
+            props.onNavigate(tab, tabEl ?? undefined)
+          }}
+          onDragEnd={(event) => {
+            const current = tabIds()
+            const source = event.operation.source
+            if (event.canceled || !isSortable(source)) return
+
+            const { initialIndex, index } = source
+            if (initialIndex !== index) {
+              props.onReorder(arrayMove(current, source.initialIndex, source.index))
+            }
+          }}
         >
-          <div data-titlebar-tab-list class="flex min-w-0 flex-row items-center" ref={listRef}>
-            <For each={displayTabs()}>
+          <div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
+            <For each={props.tabs}>
               {(tab, index) => {
                 const id = tabKey(tab)
                 let ref!: HTMLDivElement
                 useTabShortcut(index, () => props.onNavigate(tab, ref))
-
-                const dragged = () => drag.active && drag.draggedId === id
                 const serverCtx = createMemo(() => {
                   if (tab.type !== "session") return
                   const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
@@ -479,68 +264,50 @@ export function TitlebarTabStrip(props: {
                     <SessionTabSlot
                       tab={tab}
                       id={id}
+                      index={index}
                       active={() => props.currentTab() === tab}
                       activeServerKey={props.activeServerKey}
                       forceTruncate={props.forceTruncate}
-                      dragActive={drag.active}
-                      dragged={dragged}
-                      pressed={() => pressedId() === id}
                       serverCtx={serverCtx}
-                      suppressNavigation={() => suppressNavigation()}
-                      onPointerDown={(event) => {
-                        if (dragged()) return
-                        onPointerDown(id, event)
+                      onNavigate={(element) => {
+                        ref = element
+                        props.onNavigate(tab, element)
                       }}
-                      onNavigate={(element) => props.onNavigate(tab, element)}
                       onClose={() => props.onClose(tab)}
                     />
                   )
                 }
 
                 return (
-                  <DraftTabItem
-                    tabKey={id}
-                    dragActive={drag.active}
-                    onPointerDown={(event) => {
-                      if (dragged()) return
-                      onPointerDown(id, event)
-                    }}
-                    ref={ref}
-                    href={tabHref(tab)}
+                  <DraftTabSlot
+                    tab={tab}
+                    id={id}
+                    index={index}
+                    active={() => props.currentTab() === tab}
                     title={language.t("command.session.new")}
-                    onNavigate={() => props.onNavigate(tab, ref)}
+                    onNavigate={(element) => {
+                      ref = element
+                      props.onNavigate(tab, element)
+                    }}
                     onClose={() => props.onClose(tab)}
-                    suppressNavigation={() => suppressNavigation()}
-                    active={props.currentTab() === tab}
-                    pressed={pressedId() === id}
-                    hidden={dragged()}
                   />
                 )
               }}
             </For>
           </div>
-        </div>
-        <div
-          data-slot="titlebar-tabs-fade-left"
-          aria-hidden="true"
-          class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
-        />
-        <div
-          data-slot="titlebar-tabs-fade-right"
-          aria-hidden="true"
-          class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
-        />
+        </DragDropProvider>
       </div>
-      <Show when={drag.active && draggedTab() && dragPreview}>
-        {(_) => (
-          <Portal>
-            <div data-titlebar-tab-preview style={floaterStyle()}>
-              {dragPreview}
-            </div>
-          </Portal>
-        )}
-      </Show>
-    </>
+      <div
+        data-slot="titlebar-tabs-fade-left"
+        aria-hidden="true"
+        class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
+      />
+      <div
+        data-slot="titlebar-tabs-fade-right"
+        aria-hidden="true"
+        class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
+      />
+    </div>
   )
 }