{
+ 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)
+ }}
>
-
)
@@ -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 (
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)
+ }}
>
-
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)
+
{
+ 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]"
>
- {
+
+
+
+
+ {props.title}
+
+
+
+
{
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]"
- >
-
-
-
-
- {props.title}
-
-
-
- {
- 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={}
- aria-label="Close tab"
- />
-
+ 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={}
+ aria-label="Close tab"
+ />
)
diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx
index e6188fe8b5..ef404157e4 100644
--- a/packages/app/src/components/titlebar-tab-strip.tsx
+++ b/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 (
-
{
- 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()}
- />
+
+ {
+ 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()}
+ />
+
+ )
+}
+
+function DraftTabSlot(props: {
+ tab: Extract
+ 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 (
+
+ {
+ ref = el
+ }}
+ href={tabHref(props.tab)}
+ title={props.title}
+ onNavigate={() => props.onNavigate(ref)}
+ onClose={props.onClose}
+ active={props.active()}
+ dragging={sortable.isDragSource()}
+ />
+
)
}
@@ -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()
- 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("[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 (
- <>
-