Skip to content
2 changes: 1 addition & 1 deletion apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1451,7 +1451,7 @@ export default function DiffPanel({
viewerRef={attachCodeView}
onScroll={codeViewScroll.onScroll}
codeViewKey={codeViewMountKey}
className="h-full min-h-0 overflow-auto"
className="scrollbar-gutter-stable scrollbar-edge-inset h-full min-h-0 overflow-auto"
files={codeViewFiles}
pendingFiles={pendingFiles}
renderCodeViewFooter={renderLoadingBoundary}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1460,7 +1460,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
onItemSizeChanged={scheduleMinimapRefresh}
onMetricsChange={scheduleMinimapRefresh}
className={cn(
"messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]",
"messages-timeline-scroll scrollbar-gutter-both scrollbar-edge-inset h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]",
topFadeEnabled && "topbar-scroll-fade",
)}
ListHeaderComponent={listHeader}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/chat/ThreadDetailsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Popover, PopoverPopup, PopoverCreateHandle } from "../ui/popover";
import { selectThreadPanelOpen, useRightPanelStore } from "../../rightPanelStore";
import { useChatCanvas } from "./ChatCanvasContext";
import {
THREAD_DETAILS_CARD_GAP,
THREAD_DETAILS_CARD_RIGHT,
resolveThreadDetailsCardDensity,
} from "./threadDetailsCardLayout";
import { observeResize } from "../../lib/observeResize";
Expand Down Expand Up @@ -181,7 +181,7 @@ export function ThreadDetailsCard({
anchor={anchor}
align="end"
// Account for the find panel's 8px card padding.
alignOffset={THREAD_DETAILS_CARD_GAP - 8}
alignOffset={THREAD_DETAILS_CARD_RIGHT - 8}
collisionAvoidance={{ side: "shift", align: "shift", fallbackAxisSide: "none" }}
side="bottom"
sideOffset={topInset}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/ThreadFindBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function ThreadFindBar(props: ThreadFindBarProps) {
return (
<div
data-thread-find-bar
className="absolute top-3 right-3 z-40 w-[min(var(--thread-details-panel-width),calc(100%-1.5rem))] transition-opacity duration-150 ease-out starting:opacity-0 motion-reduce:transition-none"
className="absolute top-3 right-[calc(--spacing(3)+var(--app-scrollbar-edge-inset))] z-40 w-[min(var(--thread-details-panel-width),calc(100%-1.5rem-var(--app-scrollbar-edge-inset)))] transition-opacity duration-150 ease-out starting:opacity-0 motion-reduce:transition-none"
>
<InputGroup
variant="popover"
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/components/chat/chatCanvasLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,19 +332,19 @@ describe("workspace card beside chat", () => {
container: { width, height: 900 },
maxChatWidth,
composerHeight: 180,
detailsCard: { left: width - 292, right: width - 12, bottom: 400 },
detailsCard: { left: width - 296, right: width - 16, bottom: 400 },
preview: player,
});
it("keeps chat centered while the card fits beside it", () => {
expect(withCard(1500).chat).toEqual({ left: 382, width: 736, insetStart: 0, insetEnd: 0 });
});
it("narrows chat only after it reaches the left padding", () => {
expect(withCard(1108).chat).toMatchObject({ left: 48, width: 736 });
expect(withCard(1028).chat).toEqual({ left: 48, width: 656, insetStart: 0, insetEnd: 276 });
expect(withCard(1012).chat).toMatchObject({ left: 48, width: 640 });
expect(withCard(1112).chat).toMatchObject({ left: 48, width: 736 });
expect(withCard(1032).chat).toEqual({ left: 48, width: 656, insetStart: 0, insetEnd: 280 });
expect(withCard(1016).chat).toMatchObject({ left: 48, width: 640 });
});
it("reserves the marker gutter across widths where the details card fits", () => {
for (let width = 1012; width <= 1440; width++) {
for (let width = 1016; width <= 1440; width++) {
const container = { width, height: 900 };
const card = resolveThreadDetailsCardLayout({
container,
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/components/chat/threadDetailsCardLayout.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { PreviewMiniPlayerFrame } from "../preview/previewMiniPlayerLayout";
import { SCROLLBAR_EDGE_INSET } from "~/lib/scrollbarEdgeInset";
import { DETAILS_CARD_CLEARANCE } from "./chatCanvasLayout";

export const THREAD_DETAILS_CARD_GAP = 12;
// Keeps the card's gap from the chat's inset scrollbar.
export const THREAD_DETAILS_CARD_RIGHT = THREAD_DETAILS_CARD_GAP + SCROLLBAR_EDGE_INSET;
// Keep in sync with --thread-details-panel-width.
const THREAD_DETAILS_CARD_WIDTH = 280;

Expand Down Expand Up @@ -29,7 +32,8 @@ export function resolveThreadDetailsCardLayout({
}) {
const gap = THREAD_DETAILS_CARD_GAP;
const width = THREAD_DETAILS_CARD_WIDTH;
const x = container.width - width - gap;
const right = THREAD_DETAILS_CARD_RIGHT;
const x = container.width - width - right;
if (x - DETAILS_CARD_CLEARANCE - lane.padding < lane.minChatWidth) return null;
const y = gap + topInset;
const height =
Expand All @@ -39,7 +43,7 @@ export function resolveThreadDetailsCardLayout({
if (height < 160) return null;
return {
x,
right: gap,
right,
width,
y,
height,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/files/AttachmentFilePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ export function AttachmentFilePreview(props: {
<MermaidFilePreview text={content.text} />
</Suspense>
) : kind === "markdown" && rendered ? (
<ScrollArea className="min-h-0 flex-1">
<ScrollArea edgeInset className="min-h-0 flex-1">
<ChatMarkdown
text={maskMarkdownFrontmatter(content.text)}
cwd={undefined}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/files/BinaryViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ export default function BinaryViewer(props: {
<ArchiveFile key={entry.path} path={entry.path} bytes={entry.bytes} />
) : null
) : archive ? (
<div className="scrollbar-gutter-both min-h-0 flex-1 overflow-auto">
<div className="scrollbar-gutter-both scrollbar-edge-inset min-h-0 flex-1 overflow-auto">
<table
aria-label="Archive entries"
className="w-full border-separate border-spacing-0 text-xs"
Expand Down Expand Up @@ -348,7 +348,7 @@ export default function BinaryViewer(props: {
) : null}
</div>
) : rows ? (
<div className="scrollbar-gutter-both min-h-0 flex-1 overflow-auto">
<div className="scrollbar-gutter-both scrollbar-edge-inset min-h-0 flex-1 overflow-auto">
<table
aria-label={`${props.name}: ${rows.table}`}
className="min-w-full border-separate border-spacing-0 text-xs"
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -882,7 +882,7 @@ function EditableFileSurface({
<EditProvider createEditor={createFileEditor}>
<div ref={surfaceRef} className="flex min-h-0 flex-1">
<Virtualizer
className="file-preview-virtualizer min-h-0 flex-1 overflow-auto"
className="file-preview-virtualizer scrollbar-gutter-stable scrollbar-edge-inset min-h-0 flex-1 overflow-auto"
config={{
overscrollSize: 600,
intersectionObserverMargin: 1200,
Expand Down Expand Up @@ -986,7 +986,7 @@ function RenderedMarkdownSurface({
useFileSaveSession(saveTarget, onPendingChange);

return (
<ScrollArea className="min-h-0 flex-1" onKeyDown={selectRenderedContentOnSelectAll}>
<ScrollArea edgeInset className="min-h-0 flex-1" onKeyDown={selectRenderedContentOnSelectAll}>
<FileMarkdownPreview
text={contents}
cwd={cwd}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/files/MermaidViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default function MermaidViewer(props: { readonly text: string }) {
</div>
);
return (
<div className="scrollbar-gutter-both min-h-0 flex-1 overflow-auto p-6 [&_*]:animate-none!">
<div className="scrollbar-gutter-both scrollbar-edge-inset min-h-0 flex-1 overflow-auto p-6 [&_*]:animate-none!">
<MermaidDiagram source={props.text} onExpand={setExpanded} />
{expanded ? (
<ExpandedImageDialog
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/files/NotebookPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,10 @@ export default function NotebookPreview(props: {
: ""}
</div>
<FileSurfaceNotice>Saved outputs only. Code is never executed.</FileSurfaceNotice>
<div ref={scroll} className="scrollbar-gutter-both min-h-0 flex-1 overflow-auto">
<div
ref={scroll}
className="scrollbar-gutter-both scrollbar-edge-inset min-h-0 flex-1 overflow-auto"
>
<div className="mx-auto max-w-4xl space-y-5 px-5 py-6">
{props.notebook.cells.slice(0, visible).map((cell, index) => (
<section
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/files/PdfViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ export default function PdfViewer(props: {
role="region"
aria-label={props.title}
tabIndex={0}
className="relative min-h-0 min-w-0 w-full flex-1 overflow-auto scrollbar-gutter-stable bg-muted/40"
className="relative min-h-0 min-w-0 w-full flex-1 overflow-auto scrollbar-gutter-stable scrollbar-edge-inset bg-muted/40"
onScroll={(event) => {
const top = event.currentTarget.scrollTop;
setScrollTop(top);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/files/ReadOnlySourcePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default function ReadOnlySourcePreview(props: {
<DiffWorkerPoolProvider>
<Virtualizer
key={`${props.name}:${resolvedTheme}:${props.text.length}`}
className="file-preview-virtualizer min-h-0 flex-1 overflow-auto"
className="file-preview-virtualizer scrollbar-gutter-stable scrollbar-edge-inset min-h-0 flex-1 overflow-auto"
config={{ overscrollSize: 600, intersectionObserverMargin: 1200 }}
>
<File
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/onboarding/WelcomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ export function WelcomePage({
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
{/* The desktop window only moves where CSS opts in, so keep a titlebar strip. */}
{isElectron ? <WorkspacePageHeader electron /> : null}
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-gutter-both">
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-gutter-both scrollbar-edge-inset">
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col px-6 pt-12 pb-8 sm:pt-20">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
Welcome to T3 Code
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/pullRequest/PullRequestCodeTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1661,7 +1661,7 @@ function PullRequestCodeTab({
{/* The viewer must own its scroll element to position virtual rows within it. */}
<FileViewedToggleContext value={fileViewedToggle}>
<StyledDiffCodeView<ReviewAnnotationGroup>
className="@container h-full overflow-auto [scrollbar-gutter:stable]"
className="@container scrollbar-edge-inset h-full overflow-auto [scrollbar-gutter:stable]"
containerRef={scrollerRef}
viewerRef={codeViewReveal.attach}
items={items}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -827,7 +827,7 @@ export function PullRequestSummaryTab({
scrollRef.current = node;
scrollerRef(node);
}}
className="h-full overflow-y-auto"
className="scrollbar-gutter-stable scrollbar-edge-inset h-full overflow-y-auto"
data-pull-request-summary-scroll
>
<section className="px-4 pt-2.5 pb-1">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -879,7 +879,10 @@ export function PullRequestTimelineTab({

if (events.length === 0) {
return (
<div ref={scrollerRef} className="scrollbar-gutter-both h-full overflow-y-auto px-4 py-5">
<div
ref={scrollerRef}
className="scrollbar-gutter-both scrollbar-edge-inset h-full overflow-y-auto px-4 py-5"
>
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<PullRequestGlyph.pullRequest className="mb-2 size-5" />
<p className="text-xs">No activity yet.</p>
Expand All @@ -901,7 +904,7 @@ export function PullRequestTimelineTab({
keyExtractor={rowKey}
renderItem={renderRow}
estimatedItemSize={140}
className="scrollbar-gutter-both h-full px-4"
className="scrollbar-gutter-both scrollbar-edge-inset h-full px-4"
contentContainerClassName="relative mx-auto w-full max-w-3xl before:absolute before:bottom-10 before:left-[15px] before:top-6 before:w-px before:bg-border/45"
ListHeaderComponent={
anchor !== null && targetIndex < 0 && revealUrl === undefined ? (
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/pullRequest/PullRequestsColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export function PullRequestsColumn({

<div
ref={scrollRef}
className="topbar-scroll-fade scrollbar-gutter-both min-h-0 flex-1 overflow-y-auto"
className="topbar-scroll-fade scrollbar-gutter-both scrollbar-edge-inset min-h-0 flex-1 overflow-y-auto"
>
<WorkspacePageContainer width="expanded" className="min-h-full gap-4">
<div className="flex flex-col gap-3">
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/pullRequest/guide/GuideTour.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@ export function GuideTour({
}, [active]);

return (
<div ref={scrollRef} className="@container/guide h-full overflow-y-auto scrollbar-gutter-both">
<div
ref={scrollRef}
className="@container/guide h-full overflow-y-auto scrollbar-gutter-both scrollbar-edge-inset"
>
<div className="mx-auto grid max-w-[100rem] gap-6 px-4 py-4 @min-[56rem]/guide:grid-cols-[16rem_minmax(0,1fr)] @min-[90rem]/guide:gap-8 @min-[90rem]/guide:px-6">
<aside className="sticky top-4 self-start @max-[56rem]/guide:hidden">
<GuideRail stops={guide.stops} position={position} seen={tour.seen} onSelect={goTo} />
Expand Down
18 changes: 15 additions & 3 deletions apps/web/src/components/ui/scroll-area.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function ScrollArea({
scrollFadePadding = true,
scrollbarGutter = false,
hideScrollbars = false,
edgeInset = false,
chainVerticalScroll = false,
radius = "inherit",
viewportTabIndex,
Expand All @@ -35,6 +36,8 @@ function ScrollArea({
scrollFadePadding?: boolean;
scrollbarGutter?: boolean;
hideScrollbars?: boolean;
/** For a pane's main scroller: keeps the vertical thumb off the pane edge, where resize handles take the pointer. */
edgeInset?: boolean;
chainVerticalScroll?: boolean;
radius?: "inherit" | "none";
viewportTabIndex?: number;
Expand Down Expand Up @@ -67,7 +70,7 @@ function ScrollArea({
</ScrollAreaPrimitive.Viewport>
{!hideScrollbars && (
<>
<ScrollBar orientation="vertical" />
<ScrollBar orientation="vertical" edgeInset={edgeInset} />
<ScrollBar orientation="horizontal" />
<ScrollAreaPrimitive.Corner data-slot="scroll-area-corner" />
</>
Expand All @@ -79,20 +82,29 @@ function ScrollArea({
function ScrollBar({
className,
orientation = "vertical",
edgeInset = false,
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
}: ScrollAreaPrimitive.Scrollbar.Props & { edgeInset?: boolean }) {
const inset = edgeInset && orientation === "vertical";
return (
<ScrollAreaPrimitive.Scrollbar
className={cn(
"flex opacity-0 transition-opacity delay-300 data-[orientation=horizontal]:mx-1 data-[orientation=horizontal]:mb-px data-[orientation=horizontal]:h-1.5 data-[orientation=vertical]:my-1 data-[orientation=vertical]:mr-px data-[orientation=vertical]:w-1.5 data-[orientation=horizontal]:flex-col data-hovering:opacity-100 data-scrolling:opacity-100 data-hovering:delay-0 data-scrolling:delay-0 data-hovering:duration-100 data-scrolling:duration-100",
inset &&
"data-[orientation=vertical]:w-[calc(--spacing(1.5)+var(--app-scrollbar-edge-inset))]",
className,
)}
data-slot="scroll-area-scrollbar"
orientation={orientation}
{...props}
>
<ScrollAreaPrimitive.Thumb
className="relative flex-1 rounded-full bg-[var(--app-scrollbar-thumb)] transition-colors hover:bg-[var(--app-scrollbar-thumb-hover)]"
className={cn(
"relative flex-1 rounded-full bg-[var(--app-scrollbar-thumb)] transition-colors hover:bg-[var(--app-scrollbar-thumb-hover)]",
// The gap is a transparent border, so a press in it still grabs the thumb.
inset &&
"rounded-[3px_calc(3px+var(--app-scrollbar-edge-inset))_calc(3px+var(--app-scrollbar-edge-inset))_3px/3px] border-r-(length:--app-scrollbar-edge-inset) border-transparent bg-clip-padding",
)}
data-slot="scroll-area-thumb"
/>
</ScrollAreaPrimitive.Scrollbar>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ui/standalone-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { T3Wordmark } from "../T3Wordmark";
export function StandalonePage({ children }: { readonly children: ReactNode }) {
return (
// The page owns scrolling because root cannot scroll; safe centering keeps oversized cards top-aligned.
<div className="flex h-full items-center-safe justify-center overflow-y-auto scrollbar-gutter-both bg-background px-4 py-10 text-foreground sm:px-6">
<div className="flex h-full items-center-safe justify-center overflow-y-auto scrollbar-gutter-both scrollbar-edge-inset bg-background px-4 py-10 text-foreground sm:px-6">
<section className="dialog-glass w-full max-w-xl rounded-2xl border p-6">{children}</section>
</div>
);
Expand Down
24 changes: 22 additions & 2 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
}

:root {
--app-scrollbar-edge-inset: 4px;
--app-scrollbar-width: 6px;
--appearance-contrast-base: 100%;
--appearance-contrast-boost: 0%;
Expand Down Expand Up @@ -484,7 +485,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
-webkit-mask-size:
100% calc(var(--workspace-titlebar-scroll-fade-height) + 1px),
100% calc(100% - var(--workspace-titlebar-scroll-fade-height)),
var(--app-scrollbar-width) 100%;
var(--app-scrollbar-lane, var(--app-scrollbar-width)) 100%;
mask-image:
linear-gradient(
to bottom,
Expand All @@ -502,7 +503,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
mask-size:
100% calc(var(--workspace-titlebar-scroll-fade-height) + 1px),
100% calc(100% - var(--workspace-titlebar-scroll-fade-height)),
var(--app-scrollbar-width) 100%;
var(--app-scrollbar-lane, var(--app-scrollbar-width)) 100%;
}

/* Virtualizers own their native scroll element, so they cannot use ScrollArea's
Expand Down Expand Up @@ -1865,6 +1866,25 @@ code {
background: var(--app-scrollbar-thumb-hover);
}

/* For a pane's main scroller: keeps the thumb off the pane edge, where window
and panel resize handles take the pointer. The gap is a transparent thumb
border, so a press in it still grabs the thumb. Pair it with a stable
scrollbar gutter, or the wider lane shifts content when it appears. */
.scrollbar-edge-inset {
--app-scrollbar-lane: calc(var(--app-scrollbar-width) + var(--app-scrollbar-edge-inset));
}

.scrollbar-edge-inset::-webkit-scrollbar {
width: var(--app-scrollbar-lane);
}

.scrollbar-edge-inset::-webkit-scrollbar-thumb:vertical {
border-right: var(--app-scrollbar-edge-inset) solid transparent;
border-radius: 3px calc(3px + var(--app-scrollbar-edge-inset))
calc(3px + var(--app-scrollbar-edge-inset)) 3px / 3px;
background-clip: padding-box;
}

/* Chat markdown rendering */

.chat-markdown > :first-child {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/scrollbarEdgeInset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Keep in sync with --app-scrollbar-edge-inset.
export const SCROLLBAR_EDGE_INSET = 4;
Loading
Loading