From f845848837877fcde4d3828c55b1d351db59d0c8 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Thu, 8 Oct 2026 19:37:42 -0700 Subject: [PATCH] feat(web): reorder right panel tabs by dragging Tabs in the right panel tab bar can be dragged into a new order. Neighbouring tabs slide aside live while dragging and the order commits on drop. Browser reconciliation now keeps surfaces in place instead of regrouping browser tabs at the end, which would otherwise undo a user's ordering. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 9 + apps/web/src/components/RightPanelTabs.tsx | 330 ++++++++++++-------- apps/web/src/rightPanelStore.test.ts | 23 ++ apps/web/src/rightPanelStore.ts | 28 +- apps/web/src/routes/_chat.pull-requests.tsx | 4 + 5 files changed, 260 insertions(+), 134 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 06a6bacaf48c..2aeca85e6aa2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6233,6 +6233,13 @@ export default function ChatView(props: ChatViewProps) { finishRightPanelSurfaceClose, rightPanelState.surfaces, ]); + const moveRightPanelSurface = useCallback( + (surfaceId: string, toIndex: number) => { + if (activeThreadRef) + useRightPanelStore.getState().moveSurface(activeThreadRef, surfaceId, toIndex); + }, + [activeThreadRef], + ); const copyRightPanelFilePath = useCallback((relativePath: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( @@ -11991,6 +11998,7 @@ export default function ChatView(props: ChatViewProps) { onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} + onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} onAddBrowser={() => createBrowserSurface()} onAddBrowserInProfile={createBrowserSurface} @@ -12049,6 +12057,7 @@ export default function ChatView(props: ChatViewProps) { onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} + onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} onAddBrowser={() => createBrowserSurface()} onAddBrowserInProfile={createBrowserSurface} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 6c1206929b4c..5c8323a7ae34 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -14,6 +14,17 @@ import type { ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { + closestCenter, + DndContext, + type DragEndEvent, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers"; +import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { Smartphone, ChevronDown, @@ -27,6 +38,7 @@ import { } from "lucide-react"; import { Volume2, VolumeOff } from "lucide"; import { + type ComponentProps, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, @@ -115,6 +127,8 @@ interface RightPanelTabsProps { onCloseOtherSurfaces: (surface: RightPanelSurface) => void; onCloseSurfacesToRight: (surface: RightPanelSurface) => void; onCloseAllSurfaces: () => void; + /** Tabs are draggable only when the owner can persist the new order. */ + onMoveSurface?: (surfaceId: string, toIndex: number) => void; onCopyFilePath: (relativePath: string) => void; onAddBrowser: () => void; /** @@ -199,6 +213,7 @@ type TabContextMenuAction = | "close-all"; const TAB_SCROLL_EDGE_TOLERANCE = 1; +const TAB_DRAG_MODIFIERS = [restrictToHorizontalAxis, restrictToFirstScrollableAncestor]; function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { return root?.querySelector('[data-slot="scroll-area-viewport"]') ?? null; @@ -795,6 +810,61 @@ function PullRequestSurfaceIcon({ return ; } +/** + * Lets its tabs be dragged into a new order. Neighbours slide aside while dragging, + * and the order commits on drop. + */ +function SortableTabList(props: { + surfaces: readonly RightPanelSurface[]; + onMove: ((surfaceId: string, toIndex: number) => void) | undefined; + children: ReactNode; +}) { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); + const surfaceIds = useMemo(() => props.surfaces.map((surface) => surface.id), [props.surfaces]); + const { onMove, surfaces } = props; + const handleDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) return; + const toIndex = surfaces.findIndex((surface) => surface.id === over.id); + if (toIndex >= 0) onMove?.(String(active.id), toIndex); + }, + [onMove, surfaces], + ); + return ( + + + {props.children} + + + ); +} + +function SortableTab({ + surfaceId, + disabled, + className, + ...props +}: ComponentProps<"div"> & { surfaceId: string; disabled: boolean }) { + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: surfaceId, + disabled, + }); + return ( +
+ ); +} + export function RightPanelTabs(props: RightPanelTabsProps) { const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const browserProfiles = useBrowserDefaults().profiles; @@ -1118,135 +1188,143 @@ export function RightPanelTabs(props: RightPanelTabsProps) { data-right-panel-tab-list >
- {props.surfaces.map((surface) => { - const active = surface.id === props.activeSurfaceId; - const pending = props.pendingSurfaceIds.has(surface.id); - const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); - const previewTabId = previewTabIdOf(surface, props.previewSessions); - // Desktop state is keyed by the session id, but desktop actions - // must be addressed with the runtime id. - const audio = tabAudioState( - previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, - ); - const audioRuntimeTabId = previewTabId - ? (props.previewRuntimeTabId?.(previewTabId) ?? null) - : null; - return ( -
handleTabAuxClick(event, surface)} - onContextMenu={(event) => void handleTabContextMenu(event, surface)} - className={cn( - "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", - ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", - active - ? "bg-accent text-foreground" - : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", - )} - > - props.onCloseSurface(surface)} + + {props.surfaces.map((surface) => { + const active = surface.id === props.activeSurfaceId; + const pending = props.pendingSurfaceIds.has(surface.id); + const title = surfaceTitle( + surface, + props.previewSessions, + props.terminalLabelsById, + ); + const previewTabId = previewTabIdOf(surface, props.previewSessions); + // Desktop state is keyed by the session id, but desktop actions + // must be addressed with the runtime id. + const audio = tabAudioState( + previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, + ); + const audioRuntimeTabId = previewTabId + ? (props.previewRuntimeTabId?.(previewTabId) ?? null) + : null; + return ( + handleTabAuxClick(event, surface)} + onContextMenu={(event) => void handleTabContextMenu(event, surface)} + className={cn( + "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", + ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", + active + ? "bg-accent text-foreground" + : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", + )} > - - {pending ? ( - - ) : null} - - {audio === "none" || !audioRuntimeTabId ? null : ( - - { - // Sibling of the close button, inside a tab that - // activates on click: keep this to the toggle. - event.stopPropagation(); - void previewBridge - ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") - .catch(() => undefined); - }} - > - - - } + props.onCloseSurface(surface)} + > + - {audio === "muted" ? "Unmute tab" : "Mute tab"} - - )} - {renamingDevice === surface.id ? ( - { - element?.focus(); - element?.select(); - }} - onBlur={(event) => { - props.onRenameDevice?.(surface.id, event.currentTarget.value); - setRenamingDevice(null); - }} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter") event.currentTarget.blur(); - if (event.key === "Escape") { - event.currentTarget.value = title; - event.currentTarget.blur(); - } - }} - /> - ) : ( - - { - if (surface.kind === "device" && props.onRenameDevice) - setRenamingDevice(surface.id); - }} - className="cursor-pointer flex min-w-0 items-center" - onClick={() => props.onActivate(surface)} - > - {title} - - } + {pending ? ( + + ) : null} + + {audio === "none" || !audioRuntimeTabId ? null : ( + + { + // Sibling of the close button, inside a tab that + // activates on click: keep this to the toggle. + event.stopPropagation(); + void previewBridge + ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") + .catch(() => undefined); + }} + > + + + } + /> + {audio === "muted" ? "Unmute tab" : "Mute tab"} + + )} + {renamingDevice === surface.id ? ( + { + element?.focus(); + element?.select(); + }} + onBlur={(event) => { + props.onRenameDevice?.(surface.id, event.currentTarget.value); + setRenamingDevice(null); + }} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + event.currentTarget.value = title; + event.currentTarget.blur(); + } + }} /> - - {surface.kind === "device" ? ( - - ) : ( - title - )} - - - )} -
- ); - })} + ) : ( + + { + if (surface.kind === "device" && props.onRenameDevice) + setRenamingDevice(surface.id); + }} + className="cursor-pointer flex min-w-0 items-center" + onClick={() => props.onActivate(surface)} + > + {title} + + } + /> + + {surface.kind === "device" ? ( + + ) : ( + title + )} + + + )} + + ); + })} + {props.open !== false ? ( { ), ).toEqual(["terminal:term-1", "browser:tab-b", "browser:tab-c"]); }); + + it("moves a surface to a new index and keeps it there through browser reconciliation", () => { + const store = useRightPanelStore.getState(); + store.openTerminal(refA, "term-1"); + store.openBrowser(refA, "tab-a"); + store.open(refA, "diff"); + const revision = store.getUserActionRevision(refA); + const surfaceIds = () => + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map( + (surface) => surface.id, + ); + + store.moveSurface(refA, "browser:tab-a", 0); + expect(surfaceIds()).toEqual(["browser:tab-a", "terminal:term-1", "diff"]); + store.moveSurface(refA, "browser:tab-a", 2); + expect(surfaceIds()).toEqual(["terminal:term-1", "diff", "browser:tab-a"]); + store.moveSurface(refA, "browser:tab-a", 0); + + store.reconcileBrowserSurfaces(refA, ["tab-a", "tab-b"]); + expect(surfaceIds()).toEqual(["browser:tab-a", "terminal:term-1", "diff", "browser:tab-b"]); + // Reordering is not a choice about what the panel shows. + expect(store.getUserActionRevision(refA)).toBe(revision); + }); }); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 812de8804046..8afdbedfcc87 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -176,6 +176,7 @@ interface RightPanelStoreState { closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void; closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void; closeAllSurfaces: (ref: ScopedThreadRef) => void; + moveSurface: (ref: ScopedThreadRef, surfaceId: string, toIndex: number) => void; reconcileBrowserSurfaces: ( ref: ScopedThreadRef, tabIds: readonly string[], @@ -893,22 +894,33 @@ export const useRightPanelStore = create()( : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null }, ), ), + // Reordering is not a choice about what the panel shows, so it leaves the + // user-action revision alone and proactive opens still apply. + moveSurface: (ref, surfaceId, toIndex) => + set((state) => + updateThread(state, scopedThreadKey(ref), (current) => { + const fromIndex = current.surfaces.findIndex((surface) => surface.id === surfaceId); + if (fromIndex < 0 || fromIndex === toIndex) return current; + const surfaces = [...current.surfaces]; + surfaces.splice(toIndex, 0, ...surfaces.splice(fromIndex, 1)); + return { ...current, surfaces }; + }), + ), reconcileBrowserSurfaces: (ref, tabIds, hiddenTabIds) => set((state) => automaticUpdate(state, scopedThreadKey(ref), (current) => { const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`)); - const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview"); - const existingBrowser = current.surfaces.filter( - (surface): surface is Extract => - surface.kind === "preview" && - surface.id !== "browser:new" && - validIds.has(surface.id), + // Filtered in place so browser tabs keep the order the user dragged them into. + const kept = current.surfaces.filter( + (surface) => + surface.kind !== "preview" || + (surface.id !== "browser:new" && validIds.has(surface.id)), ); - const knownIds = new Set(existingBrowser.map((surface) => surface.id)); + const knownIds = new Set(kept.map((surface) => surface.id)); const added = tabIds .filter((tabId) => !knownIds.has(`browser:${tabId}`) && !hiddenTabIds?.has(tabId)) .map((tabId) => browserSurface(tabId)); - const surfaces = [...nonBrowser, ...existingBrowser, ...added]; + const surfaces = [...kept, ...added]; const activeStillExists = surfaces.some( (surface) => surface.id === current.activeSurfaceId, ); diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index c6b0591ee257..02e0e9e26d46 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -2241,6 +2241,10 @@ function PullRequestsRouteView() { if (surface.kind === "pull-request") closeSurfacesToRight(surface); }} onCloseAllSurfaces={closeAllSurfaces} + onMoveSurface={(surfaceId, toIndex) => { + if (rightPanelRef !== null) + useRightPanelStore.getState().moveSurface(rightPanelRef, surfaceId, toIndex); + }} onCopyFilePath={() => undefined} onAddBrowser={() => undefined} onAddBrowserInProfile={() => undefined}