authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-03 15:09:54-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 15:09:54-04:00
log46e8b1a23ab14fa2c128c6b315955d8eb976d85f
treed79433793cf75d869f6438c0bdf10187d98d4e3e
parentd5825e1d2fb1703ced2bbe2661f6a4937dd530bf
signature Signed by PGP key B5690EEEBB952194

fix(web): make right panel tabs easier to scroll (#9461)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

1 files changed, 134 insertions(+), 2 deletions(-)

apps/web/src/components/RightPanelTabs.tsx+134-2
......@@ -9,6 +9,8 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
99import {
1010 Bot,
1111 ChevronDown,
12 ChevronLeft,
13 ChevronRight,
1214 FileDiff,
1315 Files,
1416 GitPullRequest,
......@@ -170,6 +172,12 @@ type TabContextMenuAction =
170172 | "close-to-right"
171173 | "close-all";
172174
175const TAB_SCROLL_EDGE_TOLERANCE = 1;
176
177function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null {
178 return root?.querySelector<HTMLDivElement>('[data-slot="scroll-area-viewport"]') ?? null;
179}
180
173181/**
174182 * Desktop preview tab backing a surface, or null for non-preview surfaces, the
175183 * "new browser tab" placeholder, and the web build where no desktop tab exists.
......@@ -720,6 +728,42 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
720728 const { resolvedTheme } = useTheme();
721729 const tabListRef = useRef<HTMLDivElement>(null);
722730 const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false);
731 const [tabScrollState, setTabScrollState] = useState({
732 hasOverflow: false,
733 canScrollLeft: false,
734 canScrollRight: false,
735 });
736
737 const updateTabScrollState = useCallback(() => {
738 const viewport = tabScrollViewport(tabListRef.current);
739 if (!viewport) return;
740
741 const hasOverflow = viewport.scrollWidth - viewport.clientWidth > TAB_SCROLL_EDGE_TOLERANCE;
742 const canScrollLeft = hasOverflow && viewport.scrollLeft > TAB_SCROLL_EDGE_TOLERANCE;
743 const canScrollRight =
744 hasOverflow &&
745 viewport.scrollLeft + viewport.clientWidth < viewport.scrollWidth - TAB_SCROLL_EDGE_TOLERANCE;
746 setTabScrollState((current) => {
747 if (
748 current.hasOverflow === hasOverflow &&
749 current.canScrollLeft === canScrollLeft &&
750 current.canScrollRight === canScrollRight
751 ) {
752 return current;
753 }
754 return { hasOverflow, canScrollLeft, canScrollRight };
755 });
756 }, []);
757
758 const scrollTabs = useCallback((direction: -1 | 1) => {
759 const viewport = tabScrollViewport(tabListRef.current);
760 if (!viewport) return;
761 const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
762 viewport.scrollBy({
763 left: direction * Math.max(120, viewport.clientWidth * 0.75),
764 behavior: reduceMotion ? "auto" : "smooth",
765 });
766 }, []);
723767
724768 const addSurfaceActions = [
725769 {
......@@ -886,9 +930,49 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
886930 );
887931
888932 useEffect(() => {
933 if (!props.activeSurfaceId || !tabScrollState.hasOverflow) return;
889934 const activeTab = tabListRef.current?.querySelector<HTMLElement>("[data-active-tab='true']");
890935 activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" });
891 }, [props.activeSurfaceId]);
936 }, [props.activeSurfaceId, tabScrollState.hasOverflow]);
937
938 useEffect(() => {
939 const viewport = tabScrollViewport(tabListRef.current);
940 if (!viewport) return;
941
942 const content = viewport.firstElementChild;
943 const resizeObserver = new ResizeObserver(updateTabScrollState);
944 resizeObserver.observe(viewport);
945 if (content) resizeObserver.observe(content);
946 viewport.addEventListener("scroll", updateTabScrollState, { passive: true });
947 updateTabScrollState();
948
949 return () => {
950 resizeObserver.disconnect();
951 viewport.removeEventListener("scroll", updateTabScrollState);
952 };
953 }, [updateTabScrollState]);
954
955 useEffect(() => {
956 const viewport = tabScrollViewport(tabListRef.current);
957 if (!viewport) return;
958
959 const handleWheel = (event: WheelEvent) => {
960 if (event.ctrlKey) return;
961 let delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
962 if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) delta *= 16;
963 if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) delta *= viewport.clientWidth;
964 if (delta === 0) return;
965
966 const previousScrollLeft = viewport.scrollLeft;
967 viewport.scrollLeft += delta;
968 if (viewport.scrollLeft === previousScrollLeft) return;
969 event.preventDefault();
970 updateTabScrollState();
971 };
972
973 viewport.addEventListener("wheel", handleWheel, { passive: false });
974 return () => viewport.removeEventListener("wheel", handleWheel);
975 }, [updateTabScrollState]);
892976
893977 return (
894978 <PreviewPanelShell
......@@ -905,6 +989,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
905989 // the titlebar's height: a compact row re-centers the layout
906990 // controls a few pixels higher and the cluster jumps on open.
907991 props.mode === "inline" && !props.layoutControls ? "pr-28" : "pr-3",
992 ownsDesktopTitleBar && "drag-region",
908993 ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]",
909994 props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
910995 )}
......@@ -914,7 +999,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
914999 ref={tabListRef}
9151000 hideScrollbars
9161001 scrollFade
917 className={cn("min-w-0 flex-1 rounded-none", ownsDesktopTitleBar && "drag-region")}
1002 className={cn(
1003 "min-w-0 flex-1 rounded-none",
1004 ownsDesktopTitleBar && "[-webkit-app-region:no-drag]",
1005 )}
9181006 data-right-panel-tab-list
9191007 >
9201008 <div className="flex h-full w-max min-w-full items-center gap-1">
......@@ -1099,6 +1187,50 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
10991187 ) : null}
11001188 </div>
11011189 </ScrollArea>
1190 {tabScrollState.hasOverflow ? (
1191 <div
1192 className="flex shrink-0 items-center gap-0.5 [-webkit-app-region:no-drag]"
1193 role="group"
1194 aria-label="Scroll panel tabs"
1195 >
1196 <Tooltip>
1197 <TooltipTrigger
1198 render={
1199 <span className="inline-flex">
1200 <Button
1201 aria-label="Scroll tabs left"
1202 disabled={!tabScrollState.canScrollLeft}
1203 onClick={() => scrollTabs(-1)}
1204 size="icon-xs"
1205 variant="ghost"
1206 >
1207 <ChevronLeft />
1208 </Button>
1209 </span>
1210 }
1211 />
1212 <TooltipPopup>Scroll tabs left</TooltipPopup>
1213 </Tooltip>
1214 <Tooltip>
1215 <TooltipTrigger
1216 render={
1217 <span className="inline-flex">
1218 <Button
1219 aria-label="Scroll tabs right"
1220 disabled={!tabScrollState.canScrollRight}
1221 onClick={() => scrollTabs(1)}
1222 size="icon-xs"
1223 variant="ghost"
1224 >
1225 <ChevronRight />
1226 </Button>
1227 </span>
1228 }
1229 />
1230 <TooltipPopup>Scroll tabs right</TooltipPopup>
1231 </Tooltip>
1232 </div>
1233 ) : null}
11021234 {props.layoutControls}
11031235 </div>
11041236 <div className="flex min-h-0 flex-1 flex-col" data-right-panel-surface-content>