+
);
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 654ef108e8e75..fe3134184ea93 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -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%;
@@ -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,
@@ -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
@@ -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 {
diff --git a/apps/web/src/lib/scrollbarEdgeInset.ts b/apps/web/src/lib/scrollbarEdgeInset.ts
new file mode 100644
index 0000000000000..829c2c756cf5a
--- /dev/null
+++ b/apps/web/src/lib/scrollbarEdgeInset.ts
@@ -0,0 +1,2 @@
+// Keep in sync with --app-scrollbar-edge-inset.
+export const SCROLLBAR_EDGE_INSET = 4;
diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts
index 9de22fca7447c..104477ae6f9c7 100644
--- a/apps/web/src/terminal/ghostty/surface.ts
+++ b/apps/web/src/terminal/ghostty/surface.ts
@@ -19,6 +19,7 @@ import {
import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url";
import { isMonospaceFamily } from "../../appearanceFonts";
import { observeResize } from "../../lib/observeResize";
+import { SCROLLBAR_EDGE_INSET } from "../../lib/scrollbarEdgeInset";
const DEFAULT_TERMINAL_FONT_SIZE = 12;
const MIN_TERMINAL_FONT_SIZE = 6;
@@ -33,6 +34,8 @@ const TERMINAL_GLYPH_FALLBACKS =
const DEFAULT_TERMINAL_FONT_FAMILY =
'"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", ' + TERMINAL_GLYPH_FALLBACKS;
const CONTENT_PADDING = 4;
+// The grid leaves the scrollbar's edge inset free, so the thumb stays off the last column.
+const GRID_RIGHT_RESERVE = SCROLLBAR_EDGE_INSET;
const MIN_SCROLLBAR_THUMB_HEIGHT = 18;
const CURSOR_BLINK_INTERVAL_MS = 500;
const TERMINAL_LINK_HOVER_DELAY_MS = 250;
@@ -697,7 +700,7 @@ export class GhosttyTerminalSurface {
const scrollbar = document.createElement("div");
scrollbar.className =
- "group absolute top-1 right-px bottom-1 z-1 w-[var(--app-scrollbar-width)] cursor-default touch-none";
+ "group absolute top-1 right-px bottom-1 z-1 w-[calc(var(--app-scrollbar-width)+var(--app-scrollbar-edge-inset))] cursor-default touch-none";
scrollbar.setAttribute("role", "scrollbar");
scrollbar.setAttribute("aria-label", "Terminal scrollback");
scrollbar.setAttribute("aria-orientation", "vertical");
@@ -705,7 +708,7 @@ export class GhosttyTerminalSurface {
scrollbar.hidden = true;
const scrollbarThumb = document.createElement("div");
scrollbarThumb.className =
- "absolute inset-x-px top-0 rounded-[3px] bg-[var(--app-scrollbar-thumb)] transition-[background-color] duration-[120ms] ease-[ease-out] group-hover:bg-[var(--app-scrollbar-thumb-hover)] group-focus-visible:bg-[var(--app-scrollbar-thumb-hover)]";
+ "absolute inset-x-px top-0 border-r-(length:--app-scrollbar-edge-inset) border-transparent bg-[var(--app-scrollbar-thumb)] bg-clip-padding rounded-[3px_calc(3px+var(--app-scrollbar-edge-inset))_calc(3px+var(--app-scrollbar-edge-inset))_3px/3px] transition-[background-color] duration-[120ms] ease-[ease-out] group-hover:bg-[var(--app-scrollbar-thumb-hover)] group-focus-visible:bg-[var(--app-scrollbar-thumb-hover)]";
scrollbar.append(scrollbarThumb);
mount.replaceChildren(canvas, input, scrollbar);
@@ -720,7 +723,12 @@ export class GhosttyTerminalSurface {
} catch {}
const fontFamily = await loadTerminalFontFamily(options.font?.family, fontSize);
const metrics = measureGhosttyCell(context, fontSize, fontFamily);
- const grid = terminalGridSize(mount.clientWidth, mount.clientHeight, metrics, CONTENT_PADDING);
+ const grid = terminalGridSize(
+ mount.clientWidth - GRID_RIGHT_RESERVE,
+ mount.clientHeight,
+ metrics,
+ CONTENT_PADDING,
+ );
const core = await GhosttyTerminalCore.create(
grid.cols,
grid.rows,
@@ -897,7 +905,12 @@ export class GhosttyTerminalSurface {
// A resize can return to the painted size before its queued frame runs.
this.pendingCanvasConfiguration = null;
}
- const grid = terminalGridSize(width, height, this.metrics, CONTENT_PADDING);
+ const grid = terminalGridSize(
+ width - GRID_RIGHT_RESERVE,
+ height,
+ this.metrics,
+ CONTENT_PADDING,
+ );
this.mountHeight = height;
// onResize is the only PTY resize channel, so the first successful fit must
// notify even when the measured grid equals the 1x1 construction sentinel.
@@ -2269,7 +2282,7 @@ export class GhosttyTerminalSurface {
cellWidth: this.metrics.width,
cellHeight: this.metrics.height,
paddingLeft: CONTENT_PADDING,
- paddingRight: CONTENT_PADDING,
+ paddingRight: CONTENT_PADDING + GRID_RIGHT_RESERVE,
paddingTop: this.originY,
paddingBottom: Math.max(0, bounds.height - this.originY - this.rows * this.metrics.height),
anyButtonPressed: event.buttons !== 0,