From b9345e7f72657d154697ac56e28a7df98d87b9fd Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:31:51 +0530 Subject: [PATCH 1/4] draggable tabs --- .../app/src/components/titlebar-tab-drag.ts | 139 ++++++ .../app/src/components/titlebar-tab-nav.tsx | 140 ++++++ .../app/src/components/titlebar-tab-strip.tsx | 404 ++++++++++++++++++ packages/app/src/components/titlebar.tsx | 242 ++--------- packages/app/src/context/tabs.tsx | 10 + 5 files changed, 738 insertions(+), 197 deletions(-) create mode 100644 packages/app/src/components/titlebar-tab-drag.ts create mode 100644 packages/app/src/components/titlebar-tab-nav.tsx create mode 100644 packages/app/src/components/titlebar-tab-strip.tsx diff --git a/packages/app/src/components/titlebar-tab-drag.ts b/packages/app/src/components/titlebar-tab-drag.ts new file mode 100644 index 000000000000..4a32769ce45a --- /dev/null +++ b/packages/app/src/components/titlebar-tab-drag.ts @@ -0,0 +1,139 @@ +export type TabDragLayout = { + tabWidthById: Map + 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_LEFT_OVERSHOOT_MAX = 8 +export const STALE_POINTER_MS = 100 + +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() + const slots = list.querySelectorAll("[data-titlebar-tab-slot]") + for (const slot of slots) { + const id = slot.dataset.tabKey + if (!id) continue + const tab = slot.querySelector("[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("[data-titlebar-tab]") + if (!tab) break + dividerWidth = slot.getBoundingClientRect().width - tab.getBoundingClientRect().width + 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 + + if (currentIndex > 0) { + const seam = slotLeft(others, currentIndex, layout) + if (pointerX < seam - deadband) target = currentIndex - 1 + } + + if (target === currentIndex && currentIndex < order.length - 1) { + const seam = slotLeft(others, currentIndex + 1, layout) + if (pointerX >= seam) target = currentIndex + 1 + } + + 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 easeLeftOvershoot(overshoot: number) { + return FLOATER_LEFT_OVERSHOOT_MAX * overshoot / (overshoot + FLOATER_LEFT_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 + + if (left < stripLeft) return stripLeft - easeLeftOvershoot(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 +} diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx new file mode 100644 index 000000000000..b0f78748991e --- /dev/null +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -0,0 +1,140 @@ +import { createMemo, createResource, Show } from "solid-js" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" +import { getProjectAvatarVariant, type LocalProject } from "@/context/layout" +import { useGlobal } from "@/context/global" +import { ServerConnection } from "@/context/server" +import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers" +import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" + +function ProjectTabAvatar(props: { + project?: LocalProject + directory: string + sessionId: string + activeServer: boolean +}) { + const directory = () => props.directory + const sessionId = () => props.sessionId + const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer) + return ( + + ) +} + +export function TabNavItem(props: { + ref?: HTMLDivElement + href: string + server: ServerConnection.Key + directory: string + sessionId?: string + onClose: () => void + onNavigate: () => void + active?: boolean + activeServer: boolean + forceTruncate?: boolean + suppressNavigation?: () => boolean + dragging?: boolean + pressed?: boolean + hidden?: boolean +}) { + 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.createServerCtx(conn) + }) + const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory)) + + const [session] = createResource( + () => { + const ctx = dirSyncCtx() + if (!ctx || !props.sessionId) return + return [props.sessionId, ctx] as const + }, + async ([sessionId, dirSyncCtx]) => { + await dirSyncCtx.session.sync(sessionId).catch(() => {}) + return dirSyncCtx.session.get(sessionId) + }, + { initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined }, + ) + + return ( +
{ + if (event.button !== 1) return + closeTab(event) + }} + > + + {(session) => { + const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? [])) + + return ( + { + 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]" + > + + + + {session().title} + + ) + }} + + +
+
+ } + /> +
+
+ ) +} diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx new file mode 100644 index 000000000000..8a1f2afb67ec --- /dev/null +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -0,0 +1,404 @@ +import { + createEffect, + createMemo, + createSignal, + For, + onCleanup, + onMount, + Show, + type JSX, +} from "solid-js" +import { Portal } from "solid-js/web" +import { createStore } from "solid-js/store" +import { makeEventListener } from "@solid-primitives/event-listener" +import { decode64 } from "@/utils/base64" +import { tabHref, tabKey, type Tab } from "@/context/tabs" +import { ServerConnection } from "@/context/server" +import { TabNavItem } from "@/components/titlebar-tab-nav" +import { + ACTIVATION_DISTANCE, + autoscrollSpeed, + captureTabDragLayout, + clampFloaterLeft, + draftOrderChanged, + insertIndexFromVirtualLayout, + movePlaceholder, + pointerDistance, + STALE_POINTER_MS, + syncLayoutScroll, + type TabDragLayout, +} from "@/components/titlebar-tab-drag" + +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 + children?: JSX.Element +}) { + 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 + } + | 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 lastPointerMoveAt = 0 + + 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) + } + + 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 stale = performance.now() - lastPointerMoveAt > STALE_POINTER_MS + const strip = scrollRef.getBoundingClientRect() + const speed = autoscrollSpeed(drag.pointerX, strip.left, strip.right) + + if (speed !== 0 && !stale) { + 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) + 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) + lastPointerMoveAt = performance.now() + 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 + 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 + const tabEl = (event.currentTarget as HTMLElement).querySelector("[data-titlebar-tab]") + if (!tabEl) return + const tab = props.tabs.find((item) => tabKey(item) === id) + if (!tab) return + setSuppressNavigation(true) + // Select the tab on press (before drag threshold), matching native browser tab strips. + props.onNavigate(tab, tabEl) + setPressedId(id) + const rect = tabEl.getBoundingClientRect() + setGesture("pending", { + id, + startX: event.clientX, + startY: event.clientY, + grabOffsetX: event.clientX - rect.left, + grabOffsetY: event.clientY - rect.top, + pointerId: event.pointerId, + width: rect.width, + }) + } + + function onPointerMove(event: PointerEvent) { + const pending = gesture.pending + if (pending && !drag.active) { + if (event.pointerId !== pending.pointerId) return + if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return + startDrag(pending.id) + } + + if (!drag.active) return + if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return + + lastPointerMoveAt = performance.now() + 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() + return () => cleanups.forEach((cleanup) => cleanup()) + }) + + onCleanup(stopAutoscroll) + + createEffect(() => { + props.tabs.length + tabIds() + refreshOverflow() + }) + + createEffect(() => { + if (!drag.active || !scrollRef) return + return 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 ( + <> +
+
+ + {(tab, index) => { + const id = tabKey(tab) + const first = () => index() === 0 + let ref!: HTMLDivElement + + const dragged = () => drag.active && drag.draggedId === id + + return ( +
{ + if (dragged()) return + onPointerDown(id, event) + }} + > + props.onNavigate(tab, ref)} + onClose={() => props.onClose(tab)} + active={props.currentTab() === tab} + activeServer={tab.server === props.activeServerKey} + forceTruncate={props.forceTruncate} + suppressNavigation={() => suppressNavigation()} + pressed={pressedId() === id} + hidden={dragged()} + /> +
+ ) + }} +
+ {props.children} +
+
+ + {(tab) => ( + +
+ {}} + onClose={() => {}} + active={props.currentTab() === tab()} + activeServer={tab().server === props.activeServerKey} + forceTruncate={props.forceTruncate} + dragging + /> +
+
+ )} +
+ + ) +} diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 371cf87c5cbd..d06576ab494a 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,7 +1,6 @@ import { createEffect, createMemo, - createResource, createSignal, For, Match, @@ -21,7 +20,7 @@ import { useTheme } from "@opencode-ai/ui/theme/context" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout" +import { LayoutRoute, useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" @@ -30,15 +29,11 @@ import { WindowsAppMenu } from "./windows-app-menu" import { applyPath, backPath, forwardPath } from "./titlebar-history" import { useServerSync } from "@/context/server-sync" import { base64Encode } from "@opencode-ai/core/util/encode" -import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" -import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers" -import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" +import { TitlebarTabStrip } from "@/components/titlebar-tab-strip" import { makeEventListener } from "@solid-primitives/event-listener" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events" -import { useGlobal } from "@/context/global" -import { decode64 } from "@/utils/base64" -import { ServerConnection, useServer } from "@/context/server" -import { tabHref, useTabs, type Tab } from "@/context/tabs" +import { useServer } from "@/context/server" +import { tabHref, tabKey, useTabs, type Tab } from "@/context/tabs" type TauriDesktopWindow = { startDragging?: () => Promise @@ -406,11 +401,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { }) const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false) - let tabScrollRef!: HTMLDivElement - - function refreshTabsAreOverflowing() { - setTabsAreOverflowing(tabScrollRef.scrollWidth > tabScrollRef.clientWidth) - } return (
-
{ + navigateTab(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)} > -
- - {(tab, i) => { - let ref!: HTMLDivElement - - onMount(() => { - refreshTabsAreOverflowing() - }) - - return ( - <> - {i() !== 0 && ( -
- )} - { - navigateTab(tab) - - ref.scrollIntoView({ behavior: "instant" }) - }} - onClose={() => tabsStoreActions.removeTab(i())} - active={currentTab() === tab} - activeServer={tab.server === server.key} - forceTruncate={tabsAreOverflowing()} - /> - - ) - }} - - - {(_) => { - let ref!: HTMLDivElement - - onMount(() => { - ref.scrollIntoView({ behavior: "instant" }) - }) - - return ( - <> -
- { - const tab = tabsStore.at(-1) - if (tab) navigateTab(tab) - else navigate("/") - }} - /> - - ) - }} - -
-
+ + {(_) => { + let ref!: HTMLDivElement + + onMount(() => { + ref.scrollIntoView({ behavior: "instant" }) + }) + + return ( + <> +
+ { + const tab = tabsStore.at(-1) + if (tab) navigateTab(tab) + else navigate("/") + }} + /> + + ) + }} + + void - onNavigate: () => void - active?: boolean - activeServer: boolean - forceTruncate?: boolean -}) { - 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.createServerCtx(conn) - }) - const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory)) - - const [session] = createResource( - () => { - const ctx = dirSyncCtx() - if (!ctx || !props.sessionId) return - return [props.sessionId, ctx] as const - }, - async ([sessionId, dirSyncCtx]) => { - await dirSyncCtx.session.sync(sessionId).catch(() => {}) - return dirSyncCtx.session.get(sessionId) - }, - { initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined }, - ) - - return ( -
{ - if (event.button !== 1) return - closeTab(event) - }} - > - - {(session) => { - console.log({ session: session() }) - const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? [])) - - return ( - { - 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" - > - - - - {session().title} - - ) - }} - - -
-
- } - /> -
-
- ) -} - -function ProjectTabAvatar(props: { - project?: LocalProject - directory: string - sessionId: string - activeServer: boolean -}) { - const directory = () => props.directory - const sessionId = () => props.sessionId - const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer) - return ( - - ) -} - function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: string; onClose: () => void }) { const closeTab = (event: MouseEvent) => { event.preventDefault() diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 17374983799c..ac9368630ef6 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -83,6 +83,16 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }), ) }, + 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) + }), + ) + }, removeTab: (index: number) => { const tab = store[index] if (!tab) return From 1a2771be052354480f506adde10c3859cd404686 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:47:48 +0530 Subject: [PATCH 2/4] rubber-band on both sides --- packages/app/src/components/titlebar-tab-drag.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/titlebar-tab-drag.ts b/packages/app/src/components/titlebar-tab-drag.ts index 4a32769ce45a..8020721b166f 100644 --- a/packages/app/src/components/titlebar-tab-drag.ts +++ b/packages/app/src/components/titlebar-tab-drag.ts @@ -8,7 +8,7 @@ export const ACTIVATION_DISTANCE = 4 export const HYSTERESIS_DEADBAND = 8 export const AUTOSCROLL_EDGE = 24 export const AUTOSCROLL_MAX_SPEED = 8 -export const FLOATER_LEFT_OVERSHOOT_MAX = 8 +export const FLOATER_OVERSHOOT_MAX = 8 export const STALE_POINTER_MS = 100 export function pointerDistance(x1: number, y1: number, x2: number, y2: number) { @@ -105,8 +105,8 @@ export function draftOrderChanged(initial: readonly string[], final: readonly st return final.some((key, index) => key !== initial[index]) } -function easeLeftOvershoot(overshoot: number) { - return FLOATER_LEFT_OVERSHOOT_MAX * overshoot / (overshoot + FLOATER_LEFT_OVERSHOOT_MAX) +function easeOvershoot(overshoot: number) { + return FLOATER_OVERSHOOT_MAX * overshoot / (overshoot + FLOATER_OVERSHOOT_MAX) } export function clampFloaterLeft(left: number, width: number, stripLeft: number, stripRight: number) { @@ -114,9 +114,8 @@ export function clampFloaterLeft(left: number, width: number, stripLeft: number, if (width >= stripWidth) return stripLeft const maxLeft = stripRight - width - if (left > maxLeft) return maxLeft - - if (left < stripLeft) return stripLeft - easeLeftOvershoot(stripLeft - left) + if (left > maxLeft) return maxLeft + easeOvershoot(left - maxLeft) + if (left < stripLeft) return stripLeft - easeOvershoot(stripLeft - left) return left } From 5dcb1dea379e438d2e65a3d24b93263aac0d053b Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:00:13 +0530 Subject: [PATCH 3/4] remove autoscroll guard --- packages/app/src/components/titlebar-tab-drag.ts | 1 - packages/app/src/components/titlebar-tab-strip.tsx | 7 +------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/app/src/components/titlebar-tab-drag.ts b/packages/app/src/components/titlebar-tab-drag.ts index 8020721b166f..92c65768c7e0 100644 --- a/packages/app/src/components/titlebar-tab-drag.ts +++ b/packages/app/src/components/titlebar-tab-drag.ts @@ -9,7 +9,6 @@ export const HYSTERESIS_DEADBAND = 8 export const AUTOSCROLL_EDGE = 24 export const AUTOSCROLL_MAX_SPEED = 8 export const FLOATER_OVERSHOOT_MAX = 8 -export const STALE_POINTER_MS = 100 export function pointerDistance(x1: number, y1: number, x2: number, y2: number) { const dx = x2 - x1 diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 8a1f2afb67ec..be8718caba92 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -24,7 +24,6 @@ import { insertIndexFromVirtualLayout, movePlaceholder, pointerDistance, - STALE_POINTER_MS, syncLayoutScroll, type TabDragLayout, } from "@/components/titlebar-tab-drag" @@ -74,7 +73,6 @@ export function TitlebarTabStrip(props: { let dragLayout: TabDragLayout | undefined let dragPointerId: number | undefined let autoscrollFrame: number | undefined - let lastPointerMoveAt = 0 const tabIds = () => props.tabs.map(tabKey) @@ -107,11 +105,10 @@ export function TitlebarTabStrip(props: { function tickAutoscroll() { if (!drag.active || !scrollRef) return - const stale = performance.now() - lastPointerMoveAt > STALE_POINTER_MS const strip = scrollRef.getBoundingClientRect() const speed = autoscrollSpeed(drag.pointerX, strip.left, strip.right) - if (speed !== 0 && !stale) { + if (speed !== 0) { scrollRef.scrollLeft += speed syncScroll() } @@ -172,7 +169,6 @@ export function TitlebarTabStrip(props: { }) setPressedId(undefined) setStripScrollLeft(scrollRef.scrollLeft) - lastPointerMoveAt = performance.now() startAutoscroll() } @@ -241,7 +237,6 @@ export function TitlebarTabStrip(props: { if (!drag.active) return if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return - lastPointerMoveAt = performance.now() setDrag("pointerX", event.clientX) syncScroll() } From e6f2d17c377794a5fa5a346ed174b6fe488571a5 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:06:24 +0530 Subject: [PATCH 4/4] disable hover on other tabs when a tab is being dragged --- packages/app/src/components/titlebar-tab-strip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index be8718caba92..c5b39591ae30 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -345,7 +345,7 @@ export function TitlebarTabStrip(props: { class="flex shrink-0 touch-none" classList={{ "ml-1.5 border-l border-[var(--v2-background-bg-layer-02)] pl-1.5": !first(), - "pointer-events-none": dragged(), + "pointer-events-none": drag.active, }} onPointerDown={(event) => { if (dragged()) return