diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1f00c177c30..186bdcd0824 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3434,6 +3434,13 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadRef, diffOpen, onDiffPanelOpen], ); + const reorderRightPanelSurface = useCallback( + (surfaceId: string, targetSurfaceId: string) => { + if (!activeThreadRef) return; + useRightPanelStore.getState().reorderSurface(activeThreadRef, surfaceId, targetSurfaceId); + }, + [activeThreadRef], + ); const toggleRightPanel = useCallback(() => { if (!activeThreadRef) return; if (rightPanelOpen) { @@ -6548,6 +6555,7 @@ function ChatViewContent(props: ChatViewProps) { onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} + onReorderSurface={reorderRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} onAddBrowser={createBrowserSurface} onAddTerminal={addTerminalSurface} @@ -6582,6 +6590,7 @@ function ChatViewContent(props: ChatViewProps) { onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} + onReorderSurface={reorderRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} onAddBrowser={createBrowserSurface} onAddTerminal={addTerminalSurface} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 32c66dca6ae..f11c91e32ed 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,3 +1,15 @@ +import { + DndContext, + PointerSensor, + closestCenter, + type DragEndEvent, + type DragStartEvent, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers"; +import { SortableContext, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import type { ContextMenuItem, PreviewSessionSnapshot, PullRequestState } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { @@ -35,6 +47,7 @@ import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; +import { scrollActiveRightPanelTabIntoView } from "./rightPanelTabs.logic"; interface RightPanelTabsProps { mode: PreviewPanelMode; @@ -54,6 +67,7 @@ interface RightPanelTabsProps { onCloseOtherSurfaces: (surface: RightPanelSurface) => void; onCloseSurfacesToRight: (surface: RightPanelSurface) => void; onCloseAllSurfaces: () => void; + onReorderSurface: (surfaceId: string, targetSurfaceId: string) => void; onCopyFilePath: (relativePath: string) => void; onAddBrowser: () => void; onAddTerminal: () => void; @@ -502,10 +516,102 @@ function SurfaceIcon({ } } +function SortableSurfaceTab(props: { + surface: RightPanelSurface; + active: boolean; + tabDragActive: boolean; + pending: boolean; + title: string; + sessions: Readonly>; + theme: "light" | "dark"; + pullRequestStatuses: Readonly> | undefined; + onActivate: (surface: RightPanelSurface) => void; + onClose: (surface: RightPanelSurface) => void; + onMouseDown: (event: ReactMouseEvent) => void; + onAuxClick: (event: ReactMouseEvent, surface: RightPanelSurface) => void; + onContextMenu: (event: ReactMouseEvent, surface: RightPanelSurface) => void; +}) { + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: props.surface.id, + }); + + return ( +
props.onAuxClick(event, props.surface)} + onContextMenu={(event) => props.onContextMenu(event, props.surface)} + className={cn( + "group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs [-webkit-app-region:no-drag]", + props.tabDragActive ? "cursor-grabbing" : "cursor-pointer", + props.active + ? "bg-accent text-foreground" + : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", + isDragging && "z-10 opacity-70", + )} + > + + + props.onActivate(props.surface)} + > + {props.title} + + } + /> + {props.title} + +
+ ); +} + export function RightPanelTabs(props: RightPanelTabsProps) { const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); + const [tabDragActive, setTabDragActive] = useState(false); + const tabSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + ); const handleTabContextMenu = useCallback( async (event: ReactMouseEvent, surface: RightPanelSurface) => { @@ -577,11 +683,30 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }, [props], ); + const handleTabDragEnd = useCallback( + (event: DragEndEvent) => { + setTabDragActive(false); + const targetSurfaceId = event.over?.id; + if (targetSurfaceId === undefined || event.active.id === targetSurfaceId) return; + props.onReorderSurface(String(event.active.id), String(targetSurfaceId)); + }, + [props], + ); + const handleTabDragStart = useCallback( + (event: DragStartEvent) => { + setTabDragActive(true); + const surface = props.surfaces.find((entry) => entry.id === String(event.active.id)); + if (!surface || surface.id === props.activeSurfaceId) return; + props.onActivate(surface); + }, + [props], + ); + const handleTabDragCancel = useCallback(() => setTabDragActive(false), []); useEffect(() => { const activeTab = tabListRef.current?.querySelector("[data-active-tab='true']"); - activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); - }, [props.activeSurfaceId]); + scrollActiveRightPanelTabIntoView(activeTab ?? null, tabDragActive); + }, [props.activeSurfaceId, tabDragActive]); return ( -
- {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); - 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", - active - ? "bg-accent text-foreground" - : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", - )} - > - - - props.onActivate(surface)} - > - {title} - - } - /> - {title} - -
- ); - })} - {props.surfaces.length > 0 ? ( - - - - - - - - Browser - - - - Terminal - - - - Files - - - - Diff - - - - Pull request - - - - Agents - - - - ) : null} -
+ + surface.id)} + strategy={horizontalListSortingStrategy} + > +
+ {props.surfaces.map((surface) => ( + void handleTabContextMenu(event, entry)} + /> + ))} + {props.surfaces.length > 0 ? ( + + + + + + + + Browser + + + + Terminal + + + + Files + + + + Diff + + + + Pull request + + + + Agents + + + + ) : null} +
+
+
{props.layoutControls} diff --git a/apps/web/src/components/rightPanelTabs.logic.test.ts b/apps/web/src/components/rightPanelTabs.logic.test.ts new file mode 100644 index 00000000000..f566f1e8545 --- /dev/null +++ b/apps/web/src/components/rightPanelTabs.logic.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { scrollActiveRightPanelTabIntoView } from "./rightPanelTabs.logic"; + +describe("scrollActiveRightPanelTabIntoView", () => { + it("does not scroll the active tab into view during a tab drag", () => { + const scrollIntoView = vi.fn(); + + scrollActiveRightPanelTabIntoView({ scrollIntoView }, true); + + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it("scrolls the active tab into view outside a tab drag", () => { + const scrollIntoView = vi.fn(); + + scrollActiveRightPanelTabIntoView({ scrollIntoView }, false); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", inline: "nearest" }); + }); +}); diff --git a/apps/web/src/components/rightPanelTabs.logic.ts b/apps/web/src/components/rightPanelTabs.logic.ts new file mode 100644 index 00000000000..79704796230 --- /dev/null +++ b/apps/web/src/components/rightPanelTabs.logic.ts @@ -0,0 +1,11 @@ +interface ScrollableTab { + scrollIntoView: (options: ScrollIntoViewOptions) => void; +} + +export function scrollActiveRightPanelTabIntoView( + activeTab: ScrollableTab | null, + tabDragActive: boolean, +): void { + if (tabDragActive) return; + activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); +} diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index b6997554efb..3bb9895bebe 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -683,6 +683,37 @@ describe("rightPanelStore", () => { }); }); + it("reorders surfaces without changing the active surface", () => { + useRightPanelStore.getState().openBrowser(refA, "tab-a"); + useRightPanelStore.getState().openTerminal(refA, "term-1"); + useRightPanelStore.getState().openFile(refA, "src/index.ts"); + + useRightPanelStore.getState().reorderSurface(refA, "file:src/index.ts", "browser:tab-a"); + + const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + expect(state.surfaces.map((surface) => surface.id)).toEqual([ + "file:src/index.ts", + "browser:tab-a", + "terminal:term-1", + ]); + expect(state.activeSurfaceId).toBe("file:src/index.ts"); + }); + + it("keeps a custom surface order while reconciling browser sessions", () => { + useRightPanelStore.getState().openTerminal(refA, "term-1"); + useRightPanelStore.getState().openBrowser(refA, "tab-a"); + useRightPanelStore.getState().openBrowser(refA, "tab-b"); + useRightPanelStore.getState().reorderSurface(refA, "browser:tab-b", "terminal:term-1"); + + useRightPanelStore.getState().reconcileBrowserSurfaces(refA, ["tab-a", "tab-b", "tab-c"]); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map( + (surface) => surface.id, + ), + ).toEqual(["browser:tab-b", "terminal:term-1", "browser:tab-a", "browser:tab-c"]); + }); + it("reconciles browser surfaces without deleting other surface kinds", () => { useRightPanelStore.getState().openTerminal(refA, "term-1"); useRightPanelStore.getState().openBrowser(refA, "tab-a"); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 27d5ded5d27..5e3b4febb06 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -108,6 +108,7 @@ interface RightPanelStoreState { closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void; closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void; closeAllSurfaces: (ref: ScopedThreadRef) => void; + reorderSurface: (ref: ScopedThreadRef, surfaceId: string, targetSurfaceId: string) => void; reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void; reconcileFileSurfaces: (ref: ScopedThreadRef, workspaceAvailable: boolean) => void; show: (ref: ScopedThreadRef) => void; @@ -559,22 +560,33 @@ export const useRightPanelStore = create()( : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null }, ), })), + reorderSurface: (ref, surfaceId, targetSurfaceId) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + const fromIndex = current.surfaces.findIndex((surface) => surface.id === surfaceId); + const toIndex = current.surfaces.findIndex((surface) => surface.id === targetSurfaceId); + if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return current; + const surfaces = [...current.surfaces]; + const [surface] = surfaces.splice(fromIndex, 1); + if (!surface) return current; + surfaces.splice(toIndex, 0, surface); + return { ...current, surfaces }; + }), + })), reconcileBrowserSurfaces: (ref, tabIds) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, 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), + const existing = 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(existing.map((surface) => surface.id)); const added = tabIds .filter((tabId) => !knownIds.has(`browser:${tabId}`)) .map((tabId) => browserSurface(tabId)); - const surfaces = [...nonBrowser, ...existingBrowser, ...added]; + const surfaces = [...existing, ...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 b6ad2e1f9a9..23edd7e035f 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1511,6 +1511,10 @@ function PullRequestsRouteView() { useRightPanelStore.getState().closeAllSurfaces(rightPanelRef); selectSurfaceInUrl(null); }; + const reorderSurface = (surfaceId: string, targetSurfaceId: string) => { + if (rightPanelRef === null) return; + useRightPanelStore.getState().reorderSurface(rightPanelRef, surfaceId, targetSurfaceId); + }; return ( @@ -1544,6 +1548,7 @@ function PullRequestsRouteView() { if (surface.kind === "pull-request") closeSurfacesToRight(surface); }} onCloseAllSurfaces={closeAllSurfaces} + onReorderSurface={reorderSurface} onCopyFilePath={() => undefined} onAddBrowser={() => undefined} onAddTerminal={() => undefined} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 88a10f8daf8..b80e75b77e2 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -41,6 +41,7 @@ T3 Code works with the platforms your team already uses: - Open several reviews from the **Pull requests** page as tabs in the right panel - While working in a thread, open linked reviews in the same compact right-panel tabs without leaving the conversation +- Drag right-panel tabs to arrange them in the order that works for you - Open the review directly in your browser with one click - Command-click (Control-click on Windows and Linux) a pull request number in the sidebar to open it in your browser instead of in T3 Code - Check out a teammate's branch to review code locally