Kaynağa Gözat

feat(app): draggable tabs (#31364)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Aarav Sareen 1 ay önce
ebeveyn
işleme
d5aa5ff8fb

+ 135 - 0
packages/app/e2e/regression/cross-server-tab-close.spec.ts

@@ -0,0 +1,135 @@
+import { expect, test, type Page, type Route } from "@playwright/test"
+import { base64Encode } from "@opencode-ai/core/util/encode"
+
+const serverA = "http://127.0.0.1:4096"
+const serverB = "http://127.0.0.1:4097"
+const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
+const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
+
+test("closing the active server's last tab opens the remaining server tab", async ({ page }) => {
+  const requests: string[] = []
+  await mockServers(page, requests)
+  await page.addInitScript(
+    ({ serverB, sessionA, sessionB }) => {
+      localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
+      localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
+      localStorage.setItem(
+        "opencode.global.dat:tabs",
+        JSON.stringify([
+          { type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
+          { type: "session", server: serverB, sessionId: sessionB },
+        ]),
+      )
+    },
+    { serverB, sessionA: sessionA.id, sessionB: sessionB.id },
+  )
+
+  const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
+  const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
+  await page.goto(hrefA)
+  await expect(page.getByText(sessionA.title).first()).toBeVisible()
+
+  const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
+  await tabA.locator('[data-slot="tab-close"] button').click()
+
+  await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
+  await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true)
+  await expect(page.getByText(sessionB.title).first()).toBeVisible()
+  const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
+  expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
+  expect(
+    requests.some((request) => {
+      const url = new URL(request)
+      return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory
+    }),
+  ).toBe(true)
+})
+
+test("legacy session routes preserve an existing tab's server", async ({ page }) => {
+  await mockServers(page, [])
+  await page.addInitScript(
+    ({ serverB, sessionB }) => {
+      localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
+      localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
+      localStorage.setItem(
+        "opencode.global.dat:tabs",
+        JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
+      )
+    },
+    { serverB, sessionB: sessionB.id },
+  )
+
+  const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
+  await page.goto(`/${base64Encode(sessionB.directory)}/session/${sessionB.id}`)
+  await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
+})
+
+function session(id: string, directory: string, title: string) {
+  return {
+    id,
+    slug: id,
+    projectID: `project-${id}`,
+    directory,
+    title,
+    version: "dev",
+    time: { created: 1, updated: 1 },
+  }
+}
+
+async function mockServers(page: Page, requests: string[]) {
+  await page.route("**/*", async (route) => {
+    const url = new URL(route.request().url())
+    if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
+    requests.push(url.toString())
+    const current = url.origin === serverA ? sessionA : sessionB
+    const directory = url.searchParams.get("directory")
+    if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
+    if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
+    if (url.pathname === "/global/health") return json(route, { healthy: true })
+    if (url.pathname === "/session") return json(route, [current])
+    if (url.pathname === `/session/${current.id}`) return json(route, current)
+    if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
+    if (url.pathname === `/session/${current.id}/message`) return json(route, [])
+    if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
+    if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
+      return json(route, [])
+    if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
+      return json(route, {})
+    if (url.pathname === "/provider")
+      return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
+    if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
+    if (url.pathname === "/project" || url.pathname === "/project/current") {
+      const project = {
+        id: current.projectID,
+        worktree: current.directory,
+        vcs: "git",
+        time: { created: 1, updated: 1 },
+        sandboxes: [],
+      }
+      return json(route, url.pathname === "/project" ? [project] : project)
+    }
+    if (url.pathname === "/path")
+      return json(route, {
+        state: current.directory,
+        config: current.directory,
+        worktree: current.directory,
+        directory: current.directory,
+        home: current.directory,
+      })
+    if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
+    return json(route, {})
+  })
+}
+
+function json(route: Route, body: unknown, status = 200) {
+  return route.fulfill({
+    status,
+    contentType: "application/json",
+    headers: { "access-control-allow-origin": "*" },
+    body: JSON.stringify(body),
+  })
+}
+
+function sse(route: Route) {
+  return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
+}

+ 16 - 2
packages/app/src/app.tsx

@@ -51,7 +51,13 @@ import LegacyLayout from "@/pages/layout"
 import NewLayout from "@/pages/layout-new"
 import { ErrorPage } from "./pages/error"
 import { useCheckServerHealth } from "./utils/server-health"
-import { legacySessionHref, requireServerKey, selectSessionLineage, sessionHref } from "./utils/session-route"
+import {
+  legacySessionHref,
+  legacySessionServer,
+  requireServerKey,
+  selectSessionLineage,
+  sessionHref,
+} from "./utils/session-route"
 
 import Session from "@/pages/session"
 import { NewHome, LegacyHome } from "@/pages/home"
@@ -67,7 +73,15 @@ const SessionRoute = () => {
   const tabs = useTabs()
 
   if (params.id && settings.general.newLayoutDesigns()) {
-    return <Navigate href={sessionHref(server.key, params.id)} />
+    const sessionID = params.id
+    return (
+      <Show when={tabs.ready()}>
+        {(_) => {
+          const persisted = tabs.store.filter((item) => item.type === "session")
+          return <Navigate href={sessionHref(legacySessionServer(persisted, sessionID, server.key), sessionID)} />
+        }}
+      </Show>
+    )
   }
 
   // When the new layout is enabled, the legacy new-session route (/:dir/session with no id)

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

@@ -0,0 +1,119 @@
+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()
+  })
+})
+
+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)
+  })
+})

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

@@ -0,0 +1,135 @@
+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.querySelector<HTMLElement>("[data-titlebar-tab]")
+    if (!tab) continue
+    tabWidthById.set(id, tab.getBoundingClientRect().width)
+  }
+
+  let dividerWidth = 0
+  if (order.length >= 2) {
+    const secondId = order[1]
+    for (const slot of slots) {
+      if (slot.dataset.tabKey !== secondId) continue
+      const tab = slot.querySelector<HTMLElement>("[data-titlebar-tab]")
+      if (!tab) break
+      const style = getComputedStyle(slot)
+      dividerWidth =
+        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
+}

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

@@ -0,0 +1,37 @@
+import type { Ref } from "solid-js"
+
+export function isTabCloseTarget(target: EventTarget | null) {
+  return target instanceof Element && !!target.closest('[data-slot="tab-close"]')
+}
+
+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)
+}
+
+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
+}

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

@@ -0,0 +1,68 @@
+[data-titlebar-tab] [data-slot="tab-close"] {
+  position: absolute;
+  top: 0;
+  right: 0;
+  display: flex;
+  height: 28px;
+  width: 28px;
+  align-items: center;
+  justify-content: center;
+}
+
+[data-titlebar-tab] [data-slot="tab-close"]::before {
+  content: "";
+  position: absolute;
+  top: 0;
+  height: 28px;
+  width: 16px;
+  pointer-events: none;
+  background: linear-gradient(to right, transparent, var(--tab-bg));
+  display: none;
+}
+
+[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before {
+  display: block;
+  right: 0;
+}
+
+[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before,
+[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before {
+  right: 100%;
+}
+
+[data-titlebar-tab] [data-slot="tab-link"] {
+  padding-right: 22px;
+}
+
+[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
+  [data-slot="tab-link"] {
+  padding-right: 0;
+}
+
+[data-titlebar-tab][data-editing="true"] [data-slot="tab-link"] {
+  padding-right: 22px;
+}
+
+[data-titlebar-tab] [data-slot="tab-title"] {
+  min-width: 0;
+}
+
+[data-titlebar-tab][data-editing="true"] [data-slot="tab-title"] {
+  min-width: 0;
+  max-width: 100%;
+  flex: 1 1 auto;
+  overflow-x: auto;
+  overflow-y: hidden;
+  white-space: nowrap;
+  scrollbar-width: none;
+}
+
+[data-titlebar-tab][data-editing="true"] [data-slot="tab-title"]::-webkit-scrollbar {
+  display: none;
+}
+
+[data-titlebar-tab]:hover [data-slot="tab-close"],
+[data-titlebar-tab][data-active="true"] [data-slot="tab-close"],
+[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
+  background: var(--tab-bg);
+}

+ 344 - 0
packages/app/src/components/titlebar-tab-nav.tsx

@@ -0,0 +1,344 @@
+import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
+import { makeEventListener } from "@solid-primitives/event-listener"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
+import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { useGlobal } from "@/context/global"
+import { useLanguage } from "@/context/language"
+import { ServerConnection } from "@/context/server"
+import { projectForSession } from "@/pages/layout/helpers"
+import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
+import { showToast } from "@/utils/toast"
+import type { Session } from "@opencode-ai/sdk/v2"
+import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
+import "./titlebar-tab-nav.css"
+
+export function TabNavItem(props: {
+  ref?: Ref<HTMLDivElement>
+  href: string
+  server: ServerConnection.Key
+  session: () => Session | undefined
+  onTitleChange?: (title: string) => void
+  onTitleChangeFailed?: (title: string) => void
+  onClose: () => void
+  onNavigate: () => void
+  active?: boolean
+  activeServer: boolean
+  forceTruncate?: boolean
+  suppressNavigation?: () => boolean
+  dragging?: boolean
+  pressed?: boolean
+  hidden?: boolean
+}) {
+  const language = useLanguage()
+  const [editing, setEditing] = createSignal(false)
+  const [titleOverflowing, setTitleOverflowing] = createSignal(false)
+  let tabRoot!: HTMLDivElement
+  let titleEl!: HTMLSpanElement
+  let committing = false
+  let measureFrame: number | undefined
+
+  const closeTab = (event: MouseEvent) => {
+    event.preventDefault()
+    event.stopPropagation()
+    props.onClose()
+  }
+  const global = useGlobal()
+  const serverCtx = createMemo(() => {
+    const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
+    if (conn) return global.ensureServerCtx(conn)
+  })
+  const project = createMemo(() => {
+    const session = props.session()
+    if (!session) return
+    return projectForSession(session, serverCtx()?.projects.list() ?? [])
+  })
+
+  const measureTitleOverflow = () => {
+    if (!titleEl || editing()) {
+      setTitleOverflowing(false)
+      return
+    }
+    setTitleOverflowing(titleEl.scrollWidth > titleEl.clientWidth)
+  }
+
+  const scheduleTitleOverflow = () => {
+    if (measureFrame !== undefined) return
+    measureFrame = requestAnimationFrame(() => {
+      measureFrame = undefined
+      measureTitleOverflow()
+    })
+  }
+
+  createEffect(() => {
+    props.session()?.title
+    props.forceTruncate
+    editing()
+    scheduleTitleOverflow()
+  })
+
+  createResizeObserver(() => tabRoot, scheduleTitleOverflow)
+  onCleanup(() => {
+    if (measureFrame !== undefined) cancelAnimationFrame(measureFrame)
+  })
+
+  const selectTitle = () => {
+    const range = document.createRange()
+    range.selectNodeContents(titleEl)
+    const selection = window.getSelection()
+    selection?.removeAllRanges()
+    selection?.addRange(range)
+  }
+
+  const rename = async (title: string) => {
+    const ctx = serverCtx()
+    const session = props.session()
+    if (!ctx || !session) return
+    const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
+    await client.session.update({ sessionID: session.id, title })
+  }
+
+  const closeRename = async (save: boolean) => {
+    if (committing || !editing()) return
+    committing = true
+
+    const original = props.session()?.title ?? ""
+    const next = (titleEl.textContent ?? "").trim()
+
+    titleEl.scrollLeft = 0
+    if (save && next && next !== original) props.onTitleChange?.(next)
+    setEditing(false)
+
+    if (!save || !next || next === original) {
+      committing = false
+      return
+    }
+
+    try {
+      await rename(next)
+    } catch (err) {
+      props.onTitleChangeFailed?.(original)
+      showToast({
+        title: language.t("common.requestFailed"),
+        description: err instanceof Error ? err.message : undefined,
+      })
+    }
+
+    committing = false
+  }
+
+  createEffect(() => {
+    if (editing()) return
+    if (!titleEl) return
+    const title = props.session()?.title
+    if (title === undefined) return
+    titleEl.textContent = title
+  })
+
+  const openRename = (event: MouseEvent) => {
+    event.preventDefault()
+    event.stopPropagation()
+    if (!canOpenTabRename(props.dragging, editing(), committing)) return
+    const session = props.session()
+    if (!session) return
+    titleEl.textContent = session.title
+    setEditing(true)
+
+    requestAnimationFrame(() => {
+      titleEl.focus()
+      selectTitle()
+    })
+  }
+
+  createEffect(() => {
+    if (!editing()) return
+
+    const cleanup = makeEventListener(
+      document,
+      "pointerdown",
+      (event) => {
+        const target = event.target
+        if (!(target instanceof Node)) return
+        if (tabRoot.contains(target)) return
+        void closeRename(true)
+      },
+      { capture: true },
+    )
+
+    onCleanup(cleanup)
+  })
+
+  return (
+    <div
+      ref={(el) => {
+        tabRoot = el
+        forwardTabRef(props.ref, el)
+      }}
+      data-titlebar-tab
+      data-title-overflow={titleOverflowing()}
+      data-editing={editing()}
+      class="group relative flex h-7 min-w-24 max-w-60 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--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)
+      }}
+    >
+      <Show when={props.session()}>
+        {(session) => {
+          return (
+            <a
+              data-slot="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"
+                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>
+
+      <div data-slot="tab-close">
+        <IconButtonV2
+          size="small"
+          variant="ghost-muted"
+          class="relative z-10 opacity-0 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>
+  )
+}
+
+export function DraftTabItem(props: {
+  ref?: Ref<HTMLDivElement>
+  href: string
+  title: string
+  active?: boolean
+  onNavigate: () => void
+  onClose: () => void
+  suppressNavigation?: () => boolean
+  dragging?: boolean
+  pressed?: boolean
+  hidden?: boolean
+}) {
+  const closeTab = (event: MouseEvent) => {
+    event.preventDefault()
+    event.stopPropagation()
+    props.onClose()
+  }
+  return (
+    <div
+      ref={(el) => forwardTabRef(props.ref, el)}
+      data-titlebar-tab
+      data-active={props.active}
+      data-dragging={props.dragging}
+      data-pressed={props.pressed}
+      class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
+      classList={{ invisible: props.hidden }}
+      onMouseDown={(event) => {
+        if (event.button !== 1) return
+        closeTab(event)
+      }}
+    >
+      <a
+        data-slot="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 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
+      >
+        <span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
+          <IconV2 name="edit" />
+        </span>
+        <span class="truncate leading-5">{props.title}</span>
+      </a>
+      <div data-slot="tab-close" class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
+        <IconButtonV2
+          size="small"
+          variant="ghost-muted"
+          onPointerDown={(event) => {
+            event.preventDefault()
+            event.stopPropagation()
+          }}
+          onMouseDown={(event) => {
+            event.preventDefault()
+            event.stopPropagation()
+          }}
+          onClick={closeTab}
+          icon={<IconV2 name="xmark-small" />}
+          aria-label="Close tab"
+        />
+      </div>
+    </div>
+  )
+}

+ 581 - 0
packages/app/src/components/titlebar-tab-strip.tsx

@@ -0,0 +1,581 @@
+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 { createResizeObserver } from "@solid-primitives/resize-observer"
+import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
+import { ServerConnection } from "@/context/server"
+import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
+import { useGlobal, type ServerCtx } from "@/context/global"
+import { useLanguage } from "@/context/language"
+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"
+
+function SessionTabSlot(props: {
+  tab: SessionTab
+  id: string
+  first: () => boolean
+  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()
+  let ref!: HTMLDivElement
+  const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
+  const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
+  const [loadedSession] = createResource(
+    () => {
+      const ctx = props.serverCtx()
+      return ctx ? { id: props.tab.sessionId, ctx } : null
+    },
+    ({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
+  )
+  const session = createMemo(() => cachedSession() ?? loadedSession())
+  let prefetched = false
+
+  createEffect(() => {
+    const ctx = props.serverCtx()
+    const value = session()
+    if (!ctx || !value || prefetched) return
+    prefetched = true
+    createRoot((dispose) => {
+      try {
+        void ctx.sync
+          .ensureDirSyncContext(value.directory)
+          .session.sync(value.id)
+          .catch(() => {})
+          .finally(dispose)
+      } catch {
+        dispose()
+      }
+    })
+  })
+
+  createEffect(() => {
+    const value = session()
+    const current = sdk()
+    if (!value || !current) return
+    createTabPromptState(tabs, props.tab, current.scope, {
+      dir: base64Encode(value.directory),
+      id: value.id,
+    })
+  })
+
+  return (
+    <div
+      data-titlebar-tab-slot
+      data-tab-key={props.id}
+      class="flex shrink-0"
+      classList={{
+        hidden: !session(),
+        "ml-1.5 border-l border-[var(--v2-background-bg-layer-02)] pl-1.5": !props.first(),
+        "pointer-events-none": props.dragActive,
+      }}
+      onPointerDown={props.onPointerDown}
+    >
+      <TabNavItem
+        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()}
+      />
+    </div>
+  )
+}
+
+export function TitlebarTabStrip(props: {
+  tabs: Tab[]
+  currentTab: () => Tab | undefined
+  activeServerKey: ServerConnection.Key
+  forceTruncate: boolean
+  onNavigate: (tab: Tab, el?: HTMLDivElement) => void
+  onClose: (tab: Tab) => void
+  onReorder: (keys: string[]) => void
+  onOverflowChange: (overflowing: boolean) => void
+}) {
+  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)
+  }
+
+  createResizeObserver(
+    () => [scrollRef, listRef],
+    () => {
+      if (resizeFrame !== undefined) return
+      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 tabEl = (event.currentTarget as HTMLElement).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)
+  })
+
+  createEffect(() => {
+    props.tabs.length
+    tabIds()
+    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 class="flex min-w-0 flex-row items-center" ref={listRef}>
+            <For each={displayTabs()}>
+              {(tab, index) => {
+                const id = tabKey(tab)
+                const first = () => index() === 0
+                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)
+                  if (conn) return global.ensureServerCtx(conn)
+                })
+
+                if (tab.type === "session") {
+                  return (
+                    <SessionTabSlot
+                      tab={tab}
+                      id={id}
+                      first={first}
+                      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) => props.onNavigate(tab, element)}
+                      onClose={() => props.onClose(tab)}
+                    />
+                  )
+                }
+
+                return (
+                  <div
+                    data-titlebar-tab-slot
+                    data-tab-key={id}
+                    class="flex shrink-0"
+                    classList={{
+                      "ml-1.5 border-l border-[var(--v2-background-bg-layer-02)] pl-1.5": !first(),
+                      "pointer-events-none": drag.active,
+                    }}
+                    onPointerDown={(event) => {
+                      if (dragged()) return
+                      onPointerDown(id, event)
+                    }}
+                  >
+                    <DraftTabItem
+                      ref={ref}
+                      href={tabHref(tab)}
+                      title={language.t("command.session.new")}
+                      onNavigate={() => props.onNavigate(tab, ref)}
+                      onClose={() => props.onClose(tab)}
+                      suppressNavigation={() => suppressNavigation()}
+                      active={props.currentTab() === tab}
+                      pressed={pressedId() === id}
+                      hidden={dragged()}
+                    />
+                  </div>
+                )
+              }}
+            </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)]"
+        />
+      </div>
+      <Show when={drag.active && draggedTab() && dragPreview}>
+        {(_) => (
+          <Portal>
+            <div data-titlebar-tab-preview style={floaterStyle()}>
+              {dragPreview}
+            </div>
+          </Portal>
+        )}
+      </Show>
+    </>
+  )
+}
+
+function useTabShortcut(index: () => number, onSelect: () => void) {
+  const command = useCommand()
+
+  command.register(() => {
+    const number = index() + 1
+    if (number > 9) return []
+    return [
+      {
+        id: `tab.${number}`,
+        category: "tab",
+        title: "",
+        keybind: `mod+${number}`,
+        hidden: true,
+        onSelect,
+      },
+    ]
+  })
+}

+ 19 - 381
packages/app/src/components/titlebar.tsx

@@ -1,16 +1,4 @@
-import {
-  createEffect,
-  createMemo,
-  createResource,
-  createRoot,
-  createSignal,
-  For,
-  Match,
-  onMount,
-  Show,
-  Switch,
-  untrack,
-} from "solid-js"
+import { createEffect, createMemo, createResource, createSignal, Match, Show, Switch, untrack } from "solid-js"
 import { createStore } from "solid-js/store"
 import { useLocation, useNavigate, useParams } from "@solidjs/router"
 import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -30,19 +18,14 @@ import { useLanguage } from "@/context/language"
 import { useSettings } from "@/context/settings"
 import { WindowsAppMenu } from "./windows-app-menu"
 import { applyPath, backPath, forwardPath } from "./titlebar-history"
-import { projectForSession } from "@/pages/layout/helpers"
-import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
+import { TitlebarTabStrip } from "@/components/titlebar-tab-strip"
 import { makeEventListener } from "@solid-primitives/event-listener"
-import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { createMediaQuery } from "@solid-primitives/media"
 import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
 import { useGlobal } from "@/context/global"
 import { ServerConnection, useServer } from "@/context/server"
-import { tabHref, useTabs } from "@/context/tabs"
+import { tabKey, useTabs } from "@/context/tabs"
 import "./titlebar.css"
-import { Session } from "@opencode-ai/sdk/v2"
-import { base64Encode } from "@opencode-ai/core/util/encode"
-import { createTabPromptState } from "@/context/prompt"
 
 type TauriDesktopWindow = {
   startDragging?: () => Promise<void>
@@ -256,7 +239,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
       <Switch>
         <Match when={useV2Titlebar()}>
           {(_) => {
-            const navigate = useNavigate()
             const layout = useLayout()
             const global = useGlobal()
 
@@ -422,11 +404,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
             })
 
             const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false)
-            let tabScrollRef!: HTMLDivElement
-
-            function refreshTabsAreOverflowing() {
-              setTabsAreOverflowing(tabScrollRef.scrollWidth > tabScrollRef.clientWidth)
-            }
 
             return (
               <div
@@ -465,161 +442,22 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                   />
                 </TooltipV2>
 
-                <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={(el) => {
-                      tabScrollRef = el
-                      createResizeObserver(el, refreshTabsAreOverflowing)
-                    }}
-                  >
-                    <div
-                      class="flex min-w-0 flex-row items-center gap-1.5"
-                      ref={(el) => createResizeObserver(el, refreshTabsAreOverflowing)}
-                    >
-                      <For each={tabsStore}>
-                        {(tab, i) => {
-                          let ref!: HTMLDivElement
-                          useTabShortcut(i, () => tabs.select(tab))
-
-                          const divider = () =>
-                            i() !== 0 && (
-                              <div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
-                            )
-
-                          if (tab.type === "draft") {
-                            return (
-                              <>
-                                {divider()}
-                                <DraftTabItem
-                                  ref={ref}
-                                  href={tabHref(tab)}
-                                  title={language.t("command.session.new")}
-                                  active={currentTab() === tab}
-                                  onNavigate={() => {
-                                    tabs.select(tab)
-                                    ref.scrollIntoView({ behavior: "instant" })
-                                  }}
-                                  onClose={() => tabsStoreActions.removeTab(i())}
-                                />
-                              </>
-                            )
-                          }
-
-                          const serverCtx = createMemo(() => {
-                            const conn = server.list.find((item) => ServerConnection.key(item) === tab.server)
-                            return conn ? global.ensureServerCtx(conn) : undefined
-                          })
-                          const sdk = createMemo(() => serverCtx()?.sdk ?? null)
-                          const cachedSession = createMemo(() => serverCtx()?.sync.session.peek(tab.sessionId))
-
-                          const [loadedSession] = createResource(
-                            () => {
-                              const id = tab.sessionId
-                              const ctx = serverCtx()
-                              return ctx ? { id, ctx } : null
-                            },
-                            ({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
-                          )
-                          const session = createMemo(() => cachedSession() ?? loadedSession())
-                          let prefetched = false
-
-                          createEffect(() => {
-                            const ctx = serverCtx()
-                            const sess = session()
-                            if (!ctx || !sess || prefetched) return
-                            prefetched = true
-                            createRoot((dispose) => {
-                              try {
-                                void ctx.sync
-                                  .ensureDirSyncContext(sess.directory)
-                                  .session.sync(sess.id)
-                                  .catch(() => {})
-                                  .finally(dispose)
-                              } catch {
-                                dispose()
-                              }
-                            })
-                          })
-
-                          createEffect(() => {
-                            if (tab.type !== "session") return
-                            const _sdk = sdk()
-                            if (!_sdk) return
-                            const sess = session()
-                            if (!sess) return
-                            createTabPromptState(tabs, tab, _sdk.scope, {
-                              dir: base64Encode(sess.directory),
-                              id: sess.id,
-                            })
-                          })
-
-                          return (
-                            <>
-                              {divider()}
-                              <Show when={session()}>
-                                {(session) => (
-                                  <TabNavItem
-                                    ref={ref}
-                                    href={tabHref(tab)}
-                                    server={tab.server}
-                                    sessionId={tab.sessionId}
-                                    session={session()}
-                                    onNavigate={() => {
-                                      tabs.select(tab)
-
-                                      ref.scrollIntoView({ behavior: "instant" })
-                                    }}
-                                    onClose={() => tabsStoreActions.removeTab(i())}
-                                    active={currentTab() === tab}
-                                    activeServer={tab.server === server.key}
-                                    forceTruncate={tabsAreOverflowing()}
-                                  />
-                                )}
-                              </Show>
-                            </>
-                          )
-                        }}
-                      </For>
-                      <Show when={creating() && params.dir}>
-                        {(_) => {
-                          let ref!: HTMLDivElement
-
-                          onMount(() => {
-                            ref.scrollIntoView({ behavior: "instant" })
-                          })
-
-                          return (
-                            <>
-                              <div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
-                              <NewSessionTabItem
-                                ref={ref}
-                                href={`/${params.dir}/session`}
-                                title={language.t("command.session.new")}
-                                onClose={() => {
-                                  const tab = tabsStore.at(-1)
-                                  if (tab) tabs.select(tab)
-                                  else navigate("/")
-                                }}
-                              />
-                            </>
-                          )
-                        }}
-                      </Show>
-                    </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)]"
-                  />
-                </div>
+                <TitlebarTabStrip
+                  tabs={tabsStore}
+                  currentTab={currentTab}
+                  activeServerKey={server.key}
+                  forceTruncate={tabsAreOverflowing()}
+                  onOverflowChange={setTabsAreOverflowing}
+                  onNavigate={(tab, el) => {
+                    tabs.select(tab)
+                    el?.scrollIntoView({ behavior: "instant" })
+                  }}
+                  onClose={(tab) => {
+                    const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
+                    if (index !== -1) tabsStoreActions.removeTab(index)
+                  }}
+                  onReorder={(keys) => tabsStoreActions.reorder(keys)}
+                />
                 <Show when={!(creating() && params.dir)}>
                   <IconButtonV2
                     type="button"
@@ -852,206 +690,6 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
   )
 }
 
-function useTabShortcut(index: () => number, onSelect: () => void) {
-  const command = useCommand()
-
-  command.register(() => {
-    const number = index() + 1
-    if (number > 9) return []
-
-    return [
-      {
-        id: `tab.${number}`,
-        category: "tab",
-        title: "",
-        keybind: `mod+${number}`,
-        hidden: true,
-        onSelect,
-      },
-    ]
-  })
-}
-
-function TabNavItem(props: {
-  ref?: HTMLDivElement
-  href: string
-  server: ServerConnection.Key
-  sessionId?: string
-  hideClose?: boolean
-  onClose: () => void
-  onNavigate: () => void
-  active?: boolean
-  activeServer: boolean
-  forceTruncate?: boolean
-  session: Session
-}) {
-  const closeTab = (event: MouseEvent) => {
-    event.preventDefault()
-    event.stopPropagation()
-    props.onClose()
-  }
-
-  const global = useGlobal()
-  const serverCtx = createMemo(() => {
-    const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
-    if (conn) return global.ensureServerCtx(conn)
-  })
-
-  return (
-    <div
-      ref={props.ref}
-      class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
-      data-active={props.active}
-      onMouseDown={(event) => {
-        if (event.button !== 1) return
-        closeTab(event)
-      }}
-    >
-      <Show when={props.session}>
-        {(session) => {
-          const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
-
-          return (
-            <a
-              href={props.href}
-              onClick={(event) => {
-                event.preventDefault()
-                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"
-            >
-              <span data-slot="project-avatar-slot">
-                <SessionTabAvatar
-                  project={project()}
-                  directory={session().directory}
-                  sessionId={session().id}
-                  activeServer={props.activeServer}
-                />
-              </span>
-              <span class="min-w-0 flex-1">{session().title}</span>
-            </a>
-          )
-        }}
-      </Show>
-
-      <div
-        class="absolute not-group-hover:not-group-data-[active=true]:not-data-[truncate=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 data-[truncate=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2"
-        data-truncate={props.forceTruncate}
-      >
-        <div
-          class="absolute inset-0 rounded-r-[6px] bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
-          style={{
-            "--inactive-bg": "linear-gradient(to right, transparent 0%, var(--tab-bg) 80%)",
-            "--active-bg": "linear-gradient(90deg, transparent 0%, var(--tab-bg) 25%)",
-          }}
-        />
-        <IconButtonV2
-          size="small"
-          variant="ghost-muted"
-          class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10"
-          onClick={closeTab}
-          icon={<IconV2 name="xmark-small" />}
-        />
-      </div>
-    </div>
-  )
-}
-
-function DraftTabItem(props: {
-  ref?: HTMLDivElement
-  href: string
-  title: string
-  active?: boolean
-  onNavigate: () => void
-  onClose: () => void
-}) {
-  const closeTab = (event: MouseEvent) => {
-    event.preventDefault()
-    event.stopPropagation()
-    props.onClose()
-  }
-  return (
-    <div
-      ref={props.ref}
-      data-active={props.active}
-      class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
-      onMouseDown={(event) => {
-        if (event.button !== 1) return
-        closeTab(event)
-      }}
-    >
-      <a
-        href={props.href}
-        onClick={(event) => {
-          event.preventDefault()
-          props.onNavigate()
-        }}
-        class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
-      >
-        <span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
-          <IconV2 name="edit" />
-        </span>
-        <span class="truncate leading-5">{props.title}</span>
-      </a>
-      <div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
-        <IconButtonV2
-          size="small"
-          variant="ghost-muted"
-          onMouseDown={(event) => {
-            event.preventDefault()
-            event.stopPropagation()
-          }}
-          onClick={closeTab}
-          icon={<IconV2 name="xmark-small" />}
-          aria-label="Close tab"
-        />
-      </div>
-    </div>
-  )
-}
-
-function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: string; onClose: () => void }) {
-  const closeTab = (event: MouseEvent) => {
-    event.preventDefault()
-    event.stopPropagation()
-    props.onClose()
-  }
-  return (
-    <div
-      ref={props.ref}
-      class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
-      onMouseDown={(event) => {
-        if (event.button !== 1) return
-        closeTab(event)
-      }}
-    >
-      <a
-        href={props.href}
-        aria-current="page"
-        class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-[var(--v2-text-text-base)]"
-      >
-        <span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
-          <IconV2 name="edit" />
-        </span>
-        <span class="truncate leading-5">{props.title}</span>
-      </a>
-      <div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
-        <IconButtonV2
-          size="small"
-          variant="ghost-muted"
-          onMouseDown={(event) => {
-            event.preventDefault()
-            event.stopPropagation()
-          }}
-          onClick={closeTab}
-          icon={<IconV2 name="xmark-small" />}
-          aria-label="Close tab"
-        />
-      </div>
-    </div>
-  )
-}
-
 function ChannelIndicator() {
   return (
     <>

+ 10 - 0
packages/app/src/context/tabs.tsx

@@ -125,6 +125,16 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
         )
         return next
       },
+      reorder(keys: string[]) {
+        setStore(
+          produce((tabs) => {
+            const byKey = new Map(tabs.map((tab) => [tabKey(tab), tab]))
+            const next = keys.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab)
+            if (next.length !== tabs.length) return
+            tabs.splice(0, tabs.length, ...next)
+          }),
+        )
+      },
       draft(draftID: string) {
         const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
         if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)

+ 31 - 1
packages/app/src/utils/session-route.test.ts

@@ -1,8 +1,38 @@
 import { describe, expect, test } from "bun:test"
 import { ServerConnection } from "@/context/server"
-import { legacySessionHref, requireServerKey, rootSession, selectSessionLineage, sessionHref } from "./session-route"
+import {
+  legacySessionHref,
+  legacySessionServer,
+  requireServerKey,
+  rootSession,
+  selectSessionLineage,
+  sessionHref,
+} from "./session-route"
 
 describe("session routes", () => {
+  test("uses the unique persisted server for a legacy session route", () => {
+    expect(
+      legacySessionServer(
+        [{ type: "session", server: ServerConnection.Key.make("server-b"), sessionId: "session-1" }],
+        "session-1",
+        ServerConnection.Key.make("server-a"),
+      ),
+    ).toBe(ServerConnection.Key.make("server-b"))
+  })
+
+  test("prefers the active server when a legacy session ID is ambiguous", () => {
+    expect(
+      legacySessionServer(
+        [
+          { type: "session", server: ServerConnection.Key.make("server-a"), sessionId: "session-1" },
+          { type: "session", server: ServerConnection.Key.make("server-b"), sessionId: "session-1" },
+        ],
+        "session-1",
+        ServerConnection.Key.make("server-b"),
+      ),
+    ).toBe(ServerConnection.Key.make("server-b"))
+  })
+
   test("builds and decodes a server-keyed session route", () => {
     const server = ServerConnection.Key.make("https://example.com:4096")
     const href = sessionHref(server, "session-1")

+ 9 - 0
packages/app/src/utils/session-route.ts

@@ -16,6 +16,15 @@ export function requireServerKey(segment: string | undefined) {
   return ServerConnection.Key.make(key)
 }
 
+export function legacySessionServer(
+  tabs: readonly { type: "session"; server: ServerConnection.Key; sessionId: string }[],
+  sessionID: string,
+  active: ServerConnection.Key,
+) {
+  const matches = tabs.filter((tab) => tab.sessionId === sessionID)
+  return matches.find((tab) => tab.server === active)?.server ?? (matches.length === 1 ? matches[0]?.server : active)
+}
+
 type SessionParent = { id: string; parentID?: string }
 
 export function selectSessionLineage<T extends { session: { id: string } }>(