authorgravatar for 130890337+eimexdev@users.noreply.github.comeimexdev <130890337+eimexdev@users.noreply.github.com> 2026-10-09 22:38:39-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 02:38:39-03:00
log4107930420cffe14776ad80783f7060236b5868b
tree29c34fc77120473cb5d1d8eb7a3f89ba37b9d378
parent1f093d119b91263deb83f0d18e1504b791fdbbd4
signature Signed by PGP key B5690EEEBB952194

feat(web): reorder right panel tabs by dragging (#17730)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

5 files changed, 260 insertions(+), 134 deletions(-)

apps/web/src/components/ChatView.tsx+9
......@@ -6233,6 +6233,13 @@ export default function ChatView(props: ChatViewProps) {
62336233 finishRightPanelSurfaceClose,
62346234 rightPanelState.surfaces,
62356235 ]);
6236 const moveRightPanelSurface = useCallback(
6237 (surfaceId: string, toIndex: number) => {
6238 if (activeThreadRef)
6239 useRightPanelStore.getState().moveSurface(activeThreadRef, surfaceId, toIndex);
6240 },
6241 [activeThreadRef],
6242 );
62366243 const copyRightPanelFilePath = useCallback((relativePath: string) => {
62376244 if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
62386245 toastManager.add(
......@@ -11991,6 +11998,7 @@ export default function ChatView(props: ChatViewProps) {
1199111998 onCloseOtherSurfaces={closeOtherRightPanelSurfaces}
1199211999 onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
1199312000 onCloseAllSurfaces={closeAllRightPanelSurfaces}
12001 onMoveSurface={moveRightPanelSurface}
1199412002 onCopyFilePath={copyRightPanelFilePath}
1199512003 onAddBrowser={() => createBrowserSurface()}
1199612004 onAddBrowserInProfile={createBrowserSurface}
......@@ -12049,6 +12057,7 @@ export default function ChatView(props: ChatViewProps) {
1204912057 onCloseOtherSurfaces={closeOtherRightPanelSurfaces}
1205012058 onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
1205112059 onCloseAllSurfaces={closeAllRightPanelSurfaces}
12060 onMoveSurface={moveRightPanelSurface}
1205212061 onCopyFilePath={copyRightPanelFilePath}
1205312062 onAddBrowser={() => createBrowserSurface()}
1205412063 onAddBrowserInProfile={createBrowserSurface}
apps/web/src/components/RightPanelTabs.tsx+204-126
......@@ -14,6 +14,17 @@ import type {
1414 ResolvedKeybindingsConfig,
1515} from "@t3tools/contracts";
1616import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
17import {
18 closestCenter,
19 DndContext,
20 type DragEndEvent,
21 PointerSensor,
22 useSensor,
23 useSensors,
24} from "@dnd-kit/core";
25import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers";
26import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable";
27import { CSS } from "@dnd-kit/utilities";
1728import {
1829 Smartphone,
1930 ChevronDown,
......@@ -27,6 +38,7 @@ import {
2738} from "lucide-react";
2839import { Volume2, VolumeOff } from "lucide";
2940import {
41 type ComponentProps,
3042 type KeyboardEvent as ReactKeyboardEvent,
3143 type MouseEvent as ReactMouseEvent,
3244 type ReactElement,
......@@ -115,6 +127,8 @@ interface RightPanelTabsProps {
115127 onCloseOtherSurfaces: (surface: RightPanelSurface) => void;
116128 onCloseSurfacesToRight: (surface: RightPanelSurface) => void;
117129 onCloseAllSurfaces: () => void;
130 /** Tabs are draggable only when the owner can persist the new order. */
131 onMoveSurface?: (surfaceId: string, toIndex: number) => void;
118132 onCopyFilePath: (relativePath: string) => void;
119133 onAddBrowser: () => void;
120134 /**
......@@ -199,6 +213,7 @@ type TabContextMenuAction =
199213 | "close-all";
200214
201215const TAB_SCROLL_EDGE_TOLERANCE = 1;
216const TAB_DRAG_MODIFIERS = [restrictToHorizontalAxis, restrictToFirstScrollableAncestor];
202217
203218function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null {
204219 return root?.querySelector<HTMLDivElement>('[data-slot="scroll-area-viewport"]') ?? null;
......@@ -795,6 +810,61 @@ function PullRequestSurfaceIcon({
795810 return <presentation.Icon className={cn("size-3 shrink-0", presentation.toneClassName)} />;
796811}
797812
813/**
814 * Lets its tabs be dragged into a new order. Neighbours slide aside while dragging,
815 * and the order commits on drop.
816 */
817function SortableTabList(props: {
818 surfaces: readonly RightPanelSurface[];
819 onMove: ((surfaceId: string, toIndex: number) => void) | undefined;
820 children: ReactNode;
821}) {
822 const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
823 const surfaceIds = useMemo(() => props.surfaces.map((surface) => surface.id), [props.surfaces]);
824 const { onMove, surfaces } = props;
825 const handleDragEnd = useCallback(
826 ({ active, over }: DragEndEvent) => {
827 if (!over || active.id === over.id) return;
828 const toIndex = surfaces.findIndex((surface) => surface.id === over.id);
829 if (toIndex >= 0) onMove?.(String(active.id), toIndex);
830 },
831 [onMove, surfaces],
832 );
833 return (
834 <DndContext
835 sensors={sensors}
836 collisionDetection={closestCenter}
837 modifiers={TAB_DRAG_MODIFIERS}
838 onDragEnd={handleDragEnd}
839 >
840 <SortableContext items={surfaceIds} strategy={horizontalListSortingStrategy}>
841 {props.children}
842 </SortableContext>
843 </DndContext>
844 );
845}
846
847function SortableTab({
848 surfaceId,
849 disabled,
850 className,
851 ...props
852}: ComponentProps<"div"> & { surfaceId: string; disabled: boolean }) {
853 const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
854 id: surfaceId,
855 disabled,
856 });
857 return (
858 <div
859 {...props}
860 {...listeners}
861 ref={setNodeRef}
862 style={{ transform: CSS.Translate.toString(transform), transition }}
863 className={cn(className, isDragging && "relative z-10")}
864 />
865 );
866}
867
798868export function RightPanelTabs(props: RightPanelTabsProps) {
799869 const ownsDesktopTitleBar = isElectron && props.mode === "inline";
800870 const browserProfiles = useBrowserDefaults().profiles;
......@@ -1118,135 +1188,143 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
11181188 data-right-panel-tab-list
11191189 >
11201190 <div className="flex h-full w-max min-w-full items-center gap-1">
1121 {props.surfaces.map((surface) => {
1122 const active = surface.id === props.activeSurfaceId;
1123 const pending = props.pendingSurfaceIds.has(surface.id);
1124 const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById);
1125 const previewTabId = previewTabIdOf(surface, props.previewSessions);
1126 // Desktop state is keyed by the session id, but desktop actions
1127 // must be addressed with the runtime id.
1128 const audio = tabAudioState(
1129 previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null,
1130 );
1131 const audioRuntimeTabId = previewTabId
1132 ? (props.previewRuntimeTabId?.(previewTabId) ?? null)
1133 : null;
1134 return (
1135 <div
1136 key={surface.id}
1137 data-active-tab={active}
1138 onMouseDown={handleTabMouseDown}
1139 onAuxClick={(event) => handleTabAuxClick(event, surface)}
1140 onContextMenu={(event) => void handleTabContextMenu(event, surface)}
1141 className={cn(
1142 "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",
1143 ownsDesktopTitleBar && "[-webkit-app-region:no-drag]",
1144 active
1145 ? "bg-accent text-foreground"
1146 : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
1147 )}
1148 >
1149 <PanelTabCloseButton
1150 label={`Close ${title}`}
1151 onClick={() => props.onCloseSurface(surface)}
1191 <SortableTabList surfaces={props.surfaces} onMove={props.onMoveSurface}>
1192 {props.surfaces.map((surface) => {
1193 const active = surface.id === props.activeSurfaceId;
1194 const pending = props.pendingSurfaceIds.has(surface.id);
1195 const title = surfaceTitle(
1196 surface,
1197 props.previewSessions,
1198 props.terminalLabelsById,
1199 );
1200 const previewTabId = previewTabIdOf(surface, props.previewSessions);
1201 // Desktop state is keyed by the session id, but desktop actions
1202 // must be addressed with the runtime id.
1203 const audio = tabAudioState(
1204 previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null,
1205 );
1206 const audioRuntimeTabId = previewTabId
1207 ? (props.previewRuntimeTabId?.(previewTabId) ?? null)
1208 : null;
1209 return (
1210 <SortableTab
1211 key={surface.id}
1212 surfaceId={surface.id}
1213 disabled={!props.onMoveSurface || renamingDevice === surface.id}
1214 data-active-tab={active}
1215 onMouseDown={handleTabMouseDown}
1216 onAuxClick={(event) => handleTabAuxClick(event, surface)}
1217 onContextMenu={(event) => void handleTabContextMenu(event, surface)}
1218 className={cn(
1219 "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",
1220 ownsDesktopTitleBar && "[-webkit-app-region:no-drag]",
1221 active
1222 ? "bg-accent text-foreground"
1223 : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
1224 )}
11521225 >
1153 <SurfaceIcon
1154 surface={surface}
1155 sessions={props.previewSessions}
1156 desktopByTabId={props.desktopByTabId}
1157 theme={resolvedTheme}
1158 environmentId={props.environmentId}
1159 pullRequestStatusSeeds={props.pullRequestStatusSeeds}
1160 />
1161 {pending ? (
1162 <span
1163 className="absolute -right-0.5 -bottom-0.5 size-1.5 rounded-full bg-current"
1164 aria-hidden
1165 />
1166 ) : null}
1167 </PanelTabCloseButton>
1168 {audio === "none" || !audioRuntimeTabId ? null : (
1169 <Tooltip>
1170 <TooltipTrigger
1171 render={
1172 <button
1173 type="button"
1174 className="cursor-pointer flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted"
1175 aria-label={audio === "muted" ? `Unmute ${title}` : `Mute ${title}`}
1176 onClick={(event) => {
1177 // Sibling of the close button, inside a tab that
1178 // activates on click: keep this to the toggle.
1179 event.stopPropagation();
1180 void previewBridge
1181 ?.setAudioMuted(audioRuntimeTabId, audio !== "muted")
1182 .catch(() => undefined);
1183 }}
1184 >
1185 <MorphIcon
1186 className="size-3"
1187 icon={audio === "muted" ? VolumeOff : Volume2}
1188 />
1189 </button>
1190 }
1226 <PanelTabCloseButton
1227 label={`Close ${title}`}
1228 onClick={() => props.onCloseSurface(surface)}
1229 >
1230 <SurfaceIcon
1231 surface={surface}
1232 sessions={props.previewSessions}
1233 desktopByTabId={props.desktopByTabId}
1234 theme={resolvedTheme}
1235 environmentId={props.environmentId}
1236 pullRequestStatusSeeds={props.pullRequestStatusSeeds}
11911237 />
1192 <TooltipPopup>{audio === "muted" ? "Unmute tab" : "Mute tab"}</TooltipPopup>
1193 </Tooltip>
1194 )}
1195 {renamingDevice === surface.id ? (
1196 <input
1197 aria-label="Device tab name"
1198 className="w-24 min-w-0 rounded-sm bg-background px-1 outline-none ring-1 ring-inset ring-ring"
1199 defaultValue={title}
1200 ref={(element) => {
1201 element?.focus();
1202 element?.select();
1203 }}
1204 onBlur={(event) => {
1205 props.onRenameDevice?.(surface.id, event.currentTarget.value);
1206 setRenamingDevice(null);
1207 }}
1208 onKeyDown={(event) => {
1209 event.stopPropagation();
1210 if (event.key === "Enter") event.currentTarget.blur();
1211 if (event.key === "Escape") {
1212 event.currentTarget.value = title;
1213 event.currentTarget.blur();
1214 }
1215 }}
1216 />
1217 ) : (
1218 <Tooltip>
1219 <TooltipTrigger
1220 render={
1221 <button
1222 type="button"
1223 onDoubleClick={() => {
1224 if (surface.kind === "device" && props.onRenameDevice)
1225 setRenamingDevice(surface.id);
1226 }}
1227 className="cursor-pointer flex min-w-0 items-center"
1228 onClick={() => props.onActivate(surface)}
1229 >
1230 <span className="truncate">{title}</span>
1231 </button>
1232 }
1238 {pending ? (
1239 <span
1240 className="absolute -right-0.5 -bottom-0.5 size-1.5 rounded-full bg-current"
1241 aria-hidden
1242 />
1243 ) : null}
1244 </PanelTabCloseButton>
1245 {audio === "none" || !audioRuntimeTabId ? null : (
1246 <Tooltip>
1247 <TooltipTrigger
1248 render={
1249 <button
1250 type="button"
1251 className="cursor-pointer flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted"
1252 aria-label={audio === "muted" ? `Unmute ${title}` : `Mute ${title}`}
1253 onClick={(event) => {
1254 // Sibling of the close button, inside a tab that
1255 // activates on click: keep this to the toggle.
1256 event.stopPropagation();
1257 void previewBridge
1258 ?.setAudioMuted(audioRuntimeTabId, audio !== "muted")
1259 .catch(() => undefined);
1260 }}
1261 >
1262 <MorphIcon
1263 className="size-3"
1264 icon={audio === "muted" ? VolumeOff : Volume2}
1265 />
1266 </button>
1267 }
1268 />
1269 <TooltipPopup>{audio === "muted" ? "Unmute tab" : "Mute tab"}</TooltipPopup>
1270 </Tooltip>
1271 )}
1272 {renamingDevice === surface.id ? (
1273 <input
1274 aria-label="Device tab name"
1275 className="w-24 min-w-0 rounded-sm bg-background px-1 outline-none ring-1 ring-inset ring-ring"
1276 defaultValue={title}
1277 ref={(element) => {
1278 element?.focus();
1279 element?.select();
1280 }}
1281 onBlur={(event) => {
1282 props.onRenameDevice?.(surface.id, event.currentTarget.value);
1283 setRenamingDevice(null);
1284 }}
1285 onKeyDown={(event) => {
1286 event.stopPropagation();
1287 if (event.key === "Enter") event.currentTarget.blur();
1288 if (event.key === "Escape") {
1289 event.currentTarget.value = title;
1290 event.currentTarget.blur();
1291 }
1292 }}
12331293 />
1234 <TooltipPopup>
1235 {surface.kind === "device" ? (
1236 <DeviceTabTooltip
1237 surface={surface}
1238 environmentId={props.environmentId}
1239 title={title}
1240 />
1241 ) : (
1242 title
1243 )}
1244 </TooltipPopup>
1245 </Tooltip>
1246 )}
1247 </div>
1248 );
1249 })}
1294 ) : (
1295 <Tooltip>
1296 <TooltipTrigger
1297 render={
1298 <button
1299 type="button"
1300 onDoubleClick={() => {
1301 if (surface.kind === "device" && props.onRenameDevice)
1302 setRenamingDevice(surface.id);
1303 }}
1304 className="cursor-pointer flex min-w-0 items-center"
1305 onClick={() => props.onActivate(surface)}
1306 >
1307 <span className="truncate">{title}</span>
1308 </button>
1309 }
1310 />
1311 <TooltipPopup>
1312 {surface.kind === "device" ? (
1313 <DeviceTabTooltip
1314 surface={surface}
1315 environmentId={props.environmentId}
1316 title={title}
1317 />
1318 ) : (
1319 title
1320 )}
1321 </TooltipPopup>
1322 </Tooltip>
1323 )}
1324 </SortableTab>
1325 );
1326 })}
1327 </SortableTabList>
12501328 {props.open !== false ? (
12511329 <Menu open={addSurfaceMenuOpen} onOpenChange={setAddSurfaceMenuOpen}>
12521330 <MenuTrigger
apps/web/src/rightPanelStore.test.ts+23
......@@ -1187,4 +1187,27 @@ describe("rightPanelStore", () => {
11871187 ),
11881188 ).toEqual(["terminal:term-1", "browser:tab-b", "browser:tab-c"]);
11891189 });
1190
1191 it("moves a surface to a new index and keeps it there through browser reconciliation", () => {
1192 const store = useRightPanelStore.getState();
1193 store.openTerminal(refA, "term-1");
1194 store.openBrowser(refA, "tab-a");
1195 store.open(refA, "diff");
1196 const revision = store.getUserActionRevision(refA);
1197 const surfaceIds = () =>
1198 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map(
1199 (surface) => surface.id,
1200 );
1201
1202 store.moveSurface(refA, "browser:tab-a", 0);
1203 expect(surfaceIds()).toEqual(["browser:tab-a", "terminal:term-1", "diff"]);
1204 store.moveSurface(refA, "browser:tab-a", 2);
1205 expect(surfaceIds()).toEqual(["terminal:term-1", "diff", "browser:tab-a"]);
1206 store.moveSurface(refA, "browser:tab-a", 0);
1207
1208 store.reconcileBrowserSurfaces(refA, ["tab-a", "tab-b"]);
1209 expect(surfaceIds()).toEqual(["browser:tab-a", "terminal:term-1", "diff", "browser:tab-b"]);
1210 // Reordering is not a choice about what the panel shows.
1211 expect(store.getUserActionRevision(refA)).toBe(revision);
1212 });
11901213});
apps/web/src/rightPanelStore.ts+20-8
......@@ -176,6 +176,7 @@ interface RightPanelStoreState {
176176 closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void;
177177 closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void;
178178 closeAllSurfaces: (ref: ScopedThreadRef) => void;
179 moveSurface: (ref: ScopedThreadRef, surfaceId: string, toIndex: number) => void;
179180 reconcileBrowserSurfaces: (
180181 ref: ScopedThreadRef,
181182 tabIds: readonly string[],
......@@ -893,22 +894,33 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
893894 : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null },
894895 ),
895896 ),
897 // Reordering is not a choice about what the panel shows, so it leaves the
898 // user-action revision alone and proactive opens still apply.
899 moveSurface: (ref, surfaceId, toIndex) =>
900 set((state) =>
901 updateThread(state, scopedThreadKey(ref), (current) => {
902 const fromIndex = current.surfaces.findIndex((surface) => surface.id === surfaceId);
903 if (fromIndex < 0 || fromIndex === toIndex) return current;
904 const surfaces = [...current.surfaces];
905 surfaces.splice(toIndex, 0, ...surfaces.splice(fromIndex, 1));
906 return { ...current, surfaces };
907 }),
908 ),
896909 reconcileBrowserSurfaces: (ref, tabIds, hiddenTabIds) =>
897910 set((state) =>
898911 automaticUpdate(state, scopedThreadKey(ref), (current) => {
899912 const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`));
900 const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview");
901 const existingBrowser = current.surfaces.filter(
902 (surface): surface is Extract<RightPanelSurface, { kind: "preview" }> =>
903 surface.kind === "preview" &&
904 surface.id !== "browser:new" &&
905 validIds.has(surface.id),
913 // Filtered in place so browser tabs keep the order the user dragged them into.
914 const kept = current.surfaces.filter(
915 (surface) =>
916 surface.kind !== "preview" ||
917 (surface.id !== "browser:new" && validIds.has(surface.id)),
906918 );
907 const knownIds = new Set(existingBrowser.map((surface) => surface.id));
919 const knownIds = new Set(kept.map((surface) => surface.id));
908920 const added = tabIds
909921 .filter((tabId) => !knownIds.has(`browser:${tabId}`) && !hiddenTabIds?.has(tabId))
910922 .map((tabId) => browserSurface(tabId));
911 const surfaces = [...nonBrowser, ...existingBrowser, ...added];
923 const surfaces = [...kept, ...added];
912924 const activeStillExists = surfaces.some(
913925 (surface) => surface.id === current.activeSurfaceId,
914926 );
apps/web/src/routes/_chat.pull-requests.tsx+4
......@@ -2241,6 +2241,10 @@ function PullRequestsRouteView() {
22412241 if (surface.kind === "pull-request") closeSurfacesToRight(surface);
22422242 }}
22432243 onCloseAllSurfaces={closeAllSurfaces}
2244 onMoveSurface={(surfaceId, toIndex) => {
2245 if (rightPanelRef !== null)
2246 useRightPanelStore.getState().moveSurface(rightPanelRef, surfaceId, toIndex);
2247 }}
22442248 onCopyFilePath={() => undefined}
22452249 onAddBrowser={() => undefined}
22462250 onAddBrowserInProfile={() => undefined}