diff --git a/package.json b/package.json
index d1bc20c..3813c24 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,8 @@
"next": "^16",
"next-intl": "^4.8.3",
"next-themes": "^0.4.6",
+ "overlayscrollbars": "^2.15.1",
+ "overlayscrollbars-react": "^0.5.6",
"postcss": "^8.5.6",
"radix-ui": "^1.4.3",
"react": "^19.1.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 415c5c5..0b80b00 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -107,6 +107,12 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ overlayscrollbars:
+ specifier: ^2.15.1
+ version: 2.15.1
+ overlayscrollbars-react:
+ specifier: ^0.5.6
+ version: 0.5.6(overlayscrollbars@2.15.1)(react@19.2.4)
postcss:
specifier: ^8.5.6
version: 8.5.6
@@ -5227,6 +5233,15 @@ packages:
outvariant@1.4.3:
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
+ overlayscrollbars-react@0.5.6:
+ resolution: {integrity: sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==}
+ peerDependencies:
+ overlayscrollbars: ^2.0.0
+ react: '>=16.8.0'
+
+ overlayscrollbars@2.15.1:
+ resolution: {integrity: sha512-glX26JwjL+Tkzv0JNOWdW4VozP5dGXO+Wx8+TPrdTEJTSYT/8eJS8yXM+fewjU0nFq/JeCa+X+BqABNjC4YZSA==}
+
own-keys@1.0.1:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
@@ -12402,6 +12417,13 @@ snapshots:
outvariant@1.4.3: {}
+ overlayscrollbars-react@0.5.6(overlayscrollbars@2.15.1)(react@19.2.4):
+ dependencies:
+ overlayscrollbars: 2.15.1
+ react: 19.2.4
+
+ overlayscrollbars@2.15.1: {}
+
own-keys@1.0.1:
dependencies:
get-intrinsic: 1.3.0
diff --git a/src/app/globals.css b/src/app/globals.css
index a1276cb..0cc9622 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -2,7 +2,6 @@
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@xterm/xterm/css/xterm.css";
-
@custom-variant dark (&:is(.dark *));
:root {
@@ -1002,14 +1001,28 @@
}
}
-/* Unified scrollbar style for scrollable containers.
- Thin overlay scrollbar — no gutter reserved, no layout shift. */
-.scrollbar-thin,
-.scrollbar-thin-edge {
+/* Native fallback for containers that cannot use OverlayScrollbars (e.g. virtua) */
+.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
+/* OverlayScrollbars custom theme */
+.os-theme-codeg {
+ --os-size: 6px;
+ --os-handle-bg: var(--border);
+ --os-handle-bg-hover: var(--muted-foreground);
+ --os-handle-bg-active: var(--muted-foreground);
+ --os-handle-border-radius: 999px;
+ --os-handle-perpendicular-size: 100%;
+ --os-handle-perpendicular-size-hover: 100%;
+ --os-handle-perpendicular-size-active: 100%;
+}
+
+.os-theme-codeg:hover {
+ --os-size: 8px;
+}
+
/* Streamdown code blocks: dark mode via shiki dual-theme CSS variables */
.dark [data-streamdown="code-block-body"] {
background-color: var(--shiki-dark-bg, var(--sdm-bg, transparent)) !important;
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 1d7acb1..fe63bdd 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -10,6 +10,7 @@ import { ThemeProvider } from "@/components/theme-provider"
import { toIntlLocale } from "@/lib/i18n"
import { APPEARANCE_INIT_SCRIPT } from "@/lib/appearance-script"
import { AppearanceProvider } from "@/components/appearance-provider"
+import { OverlayScrollbarsInit } from "@/components/overlay-scrollbars-init"
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
@@ -68,7 +69,10 @@ export default async function RootLayout({
enableSystem
disableTransitionOnChange
>
- {children}
+
+
+ {children}
+
diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx
index 89c27bf..09aa513 100644
--- a/src/components/conversations/sidebar-conversation-list.tsx
+++ b/src/components/conversations/sidebar-conversation-list.tsx
@@ -28,6 +28,7 @@ import { STATUS_ORDER, STATUS_COLORS } from "@/lib/types"
import { SidebarConversationCard } from "./sidebar-conversation-card"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
+import { ScrollArea } from "@/components/ui/scroll-area"
import {
ContextMenu,
ContextMenuTrigger,
@@ -205,8 +206,6 @@ export function SidebarConversationList({
cancelled: false,
})
- const scrollContainerRef = useRef(null)
-
const scrollToActiveRef = useRef<() => void>(() => {})
const pendingScrollRef = useRef(false)
const virtualizerRef = useRef(null)
@@ -479,12 +478,8 @@ export function SidebarConversationList({
) : (
-
{flatItems.map((item) => {
@@ -537,7 +532,7 @@ export function SidebarConversationList({
)
})}
-
+
diff --git a/src/components/diff/unified-diff-preview.tsx b/src/components/diff/unified-diff-preview.tsx
index ad1e127..4a38478 100644
--- a/src/components/diff/unified-diff-preview.tsx
+++ b/src/components/diff/unified-diff-preview.tsx
@@ -4,6 +4,7 @@ import { useMemo } from "react"
import { useTranslations } from "next-intl"
import { useFolderContext } from "@/contexts/folder-context"
import { cn } from "@/lib/utils"
+import { ScrollArea } from "@/components/ui/scroll-area"
type RowMarker = "none" | "added" | "deleted" | "modified"
type DiffFileMode = "modified" | "added" | "deleted" | "renamed"
@@ -546,16 +547,16 @@ export function UnifiedDiffPreview({
if (files.length === 0) {
return (
-
+
)
}
return (
-
+
{files.map((file) => {
const newFile = isNewFileOnly(file)
@@ -586,7 +587,7 @@ export function UnifiedDiffPreview({
)}
-
+
{newFile
? file.hunks.map((hunk) => (
@@ -599,11 +600,11 @@ export function UnifiedDiffPreview({
))}
-
+
)
})}
-
+
)
}
diff --git a/src/components/files/file-workspace-panel.tsx b/src/components/files/file-workspace-panel.tsx
index bbbbb2f..85661df 100644
--- a/src/components/files/file-workspace-panel.tsx
+++ b/src/components/files/file-workspace-panel.tsx
@@ -24,6 +24,7 @@ import { Streamdown } from "streamdown"
import { readFileBase64 } from "@/lib/api"
import { normalizeMathDelimiters } from "@/components/ai-elements/message"
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
+import { ScrollArea } from "@/components/ui/scroll-area"
import "@/lib/monaco-local"
const math = createMathPlugin({ singleDollarTextMath: true })
@@ -693,7 +694,7 @@ function DiffFileList({
)}
-
+
{diffOutline.files.map((file) => (
@@ -743,7 +744,7 @@ function DiffFileList({
))}
-
+
)
}
diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx
index 7a4551d..b2e7871 100644
--- a/src/components/layout/aux-panel-file-tree-tab.tsx
+++ b/src/components/layout/aux-panel-file-tree-tab.tsx
@@ -37,6 +37,7 @@ import {
stopFileTreeWatch,
} from "@/lib/api"
import { emitAttachFileToSession } from "@/lib/session-attachment-events"
+import { ScrollArea } from "@/components/ui/scroll-area"
import type {
FileTreeChangedEvent,
FileTreeNode,
@@ -2167,7 +2168,7 @@ export function FileTreeTab() {
-
+
)}
-
+
diff --git a/src/components/layout/aux-panel-git-changes-tab.tsx b/src/components/layout/aux-panel-git-changes-tab.tsx
index 7f44a3b..a87983f 100644
--- a/src/components/layout/aux-panel-git-changes-tab.tsx
+++ b/src/components/layout/aux-panel-git-changes-tab.tsx
@@ -27,6 +27,7 @@ import {
FileTreeFolder,
} from "@/components/ai-elements/file-tree"
import { Button } from "@/components/ui/button"
+import { ScrollArea } from "@/components/ui/scroll-area"
import {
ContextMenu,
ContextMenuContent,
@@ -1287,7 +1288,7 @@ export function GitChangesTab() {
return (
<>
-
+
{trackedChanges.length === 0 && untrackedChanges.length === 0 ? (
@@ -1506,7 +1507,7 @@ export function GitChangesTab() {
)}
)}
-
+
+
)
}
if (error) {
return (
-
+
{hasBranches && (
-
+
)
}
if (entries.length === 0) {
return (
-
- {hasBranches && (
-
- )}
-
-
- {t("noCommitsFound")}
-
+
+
+ {hasBranches && (
+
+ )}
+
+
+ {t("noCommitsFound")}
+
+
-
+
)
}
@@ -983,256 +986,258 @@ export function GitLogTab() {
-
- {hasBranches && (
-
-
-
- )}
- {entries.map((entry) => {
- const commitKey = entry.full_hash
- const commitDate = parseDate(entry.date)
- const pushStatus = getPushStatusMeta(
- entry.pushed,
- pushStatusLabels
- )
- const PushStatusIcon = pushStatus.icon
- const commitBranches = branchesByCommit[commitKey]
- const isBranchLoading = !!branchesLoading[commitKey]
- const branchError = branchesError[commitKey]
- const isOpen = !!openByCommit[commitKey]
+
+ {hasBranches && (
+
+
+
+ )}
+ {entries.map((entry) => {
+ const commitKey = entry.full_hash
+ const commitDate = parseDate(entry.date)
+ const pushStatus = getPushStatusMeta(
+ entry.pushed,
+ pushStatusLabels
+ )
+ const PushStatusIcon = pushStatus.icon
+ const commitBranches = branchesByCommit[commitKey]
+ const isBranchLoading = !!branchesLoading[commitKey]
+ const branchError = branchesError[commitKey]
+ const isOpen = !!openByCommit[commitKey]
- return (
-
-
-
-
{
- setOpenByCommit((prev) => ({
- ...prev,
- [commitKey]: open,
- }))
- if (open) {
- void fetchCommitBranches(commitKey)
- }
- }}
- open={isOpen}
- >
-
-
-
- {entry.message}
-
-
-
-
-
- {entry.author}
-
- {formatRelativeTime(entry.date, t)}
-
-
- {entry.hash}
-
-
-
-
-
-
-
-
-
-
-
- {t("hash")}
-
-
-
- {entry.full_hash}
-
-
-
-
- {t("author")}
-
-
-
- {entry.author}
-
-
- ·
-
-
-
-
-
-
+ return (
+
+
+
+ {
+ setOpenByCommit((prev) => ({
+ ...prev,
+ [commitKey]: open,
+ }))
+ if (open) {
+ void fetchCommitBranches(commitKey)
+ }
+ }}
+ open={isOpen}
+ >
+
+
+
{entry.message}
-
-
-
- {entry.files.length === 0 ? (
-
-
- {t("filesTitle")}
-
-
- {t("noFileChangeDetails")}
-
+
+
+
+
+
+ {entry.author}
+
+ {formatRelativeTime(entry.date, t)}
+
+
+ {entry.hash}
+
+
+
+
+
+
+
+
+
+
+
+ {t("hash")}
+
+
+
+ {entry.full_hash}
+
+
+
+
+ {t("author")}
+
+
+
+ {entry.author}
+
+
+ ·
+
+
+
- ) : (
-
- )}
-
-
- {t("branchesTitle")}
-
- {isBranchLoading ? (
-
- {t("loadingBranches")}
+
+
+ {entry.message}
- ) : branchError ? (
-
- {branchError}
-
- ) : commitBranches &&
- commitBranches.length > 0 ? (
-
- {commitBranches.map((branch) => (
-
- {branch}
-
- ))}
+
+
+ {entry.files.length === 0 ? (
+
+
+ {t("filesTitle")}
+
+
+ {t("noFileChangeDetails")}
+
) : (
-
- {t("noContainingBranches")}
-
+
)}
+
+
+ {t("branchesTitle")}
+
+ {isBranchLoading ? (
+
+ {t("loadingBranches")}
+
+ ) : branchError ? (
+
+ {branchError}
+
+ ) : commitBranches &&
+ commitBranches.length > 0 ? (
+
+ {commitBranches.map((branch) => (
+
+ {branch}
+
+ ))}
+
+ ) : (
+
+ {t("noContainingBranches")}
+
+ )}
+
-
-
-
-
-
-
- {
- handleOpenNewBranchDialog(entry)
- }}
- >
-
- {t("newBranch")}
-
- {
- void openCommitDiff(
- entry.full_hash,
- undefined,
- entry.message
- )
- }}
- >
-
- {tCommon("viewDiff")}
-
- {
- void fetchLog()
- }}
- >
-
- {tCommon("refresh")}
-
- {
- if (!folder) return
- openPushWindow(folder.id).catch((err) => {
- const msg = toErrorMessage(err)
- toast.error(t("toasts.openPushWindowFailed"), {
- description: msg,
+
+
+
+
+
+ {
+ handleOpenNewBranchDialog(entry)
+ }}
+ >
+
+ {t("newBranch")}
+
+ {
+ void openCommitDiff(
+ entry.full_hash,
+ undefined,
+ entry.message
+ )
+ }}
+ >
+
+ {tCommon("viewDiff")}
+
+ {
+ void fetchLog()
+ }}
+ >
+
+ {tCommon("refresh")}
+
+ {
+ if (!folder) return
+ openPushWindow(folder.id).catch((err) => {
+ const msg = toErrorMessage(err)
+ toast.error(t("toasts.openPushWindowFailed"), {
+ description: msg,
+ })
})
- })
- }}
- >
-
- {tCommon("push")}
-
-
-
- )
- })}
-
+ }}
+ >
+
+ {tCommon("push")}
+
+
+
+ )
+ })}
+
+
-
+
-
+
)
}
diff --git a/src/components/overlay-scrollbars-init.tsx b/src/components/overlay-scrollbars-init.tsx
new file mode 100644
index 0000000..41762ff
--- /dev/null
+++ b/src/components/overlay-scrollbars-init.tsx
@@ -0,0 +1,25 @@
+"use client"
+
+import { useEffect } from "react"
+import "overlayscrollbars/overlayscrollbars.css"
+import { useOverlayScrollbars } from "overlayscrollbars-react"
+
+export function OverlayScrollbarsInit() {
+ const [init] = useOverlayScrollbars({
+ options: {
+ scrollbars: {
+ theme: "os-theme-codeg",
+ autoHide: "leave",
+ clickScroll: true,
+ },
+ overflow: { x: "hidden" },
+ },
+ defer: true,
+ })
+
+ useEffect(() => {
+ init(document.body)
+ }, [init])
+
+ return null
+}
diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx
index 358697b..6308fb9 100644
--- a/src/components/ui/scroll-area.tsx
+++ b/src/components/ui/scroll-area.tsx
@@ -1,55 +1,59 @@
"use client"
-import * as React from "react"
-import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
+import { useMemo } from "react"
+import {
+ OverlayScrollbarsComponent,
+ type OverlayScrollbarsComponentRef,
+} from "overlayscrollbars-react"
+import type { OverlayScrollbarsComponentProps } from "overlayscrollbars-react"
-import { cn } from "@/lib/utils"
+type ScrollAreaProps = {
+ children: React.ReactNode
+ className?: string
+ x?: "scroll" | "hidden"
+ y?: "scroll" | "hidden"
+ onScroll?: (event: Event) => void
+ ref?: React.Ref
+}
-function ScrollArea({
- className,
+const BASE_OPTIONS: OverlayScrollbarsComponentProps["options"] = {
+ scrollbars: {
+ theme: "os-theme-codeg",
+ autoHide: "leave",
+ clickScroll: true,
+ },
+}
+
+export function ScrollArea({
children,
- ...props
-}: React.ComponentProps) {
- return (
-
-
- {children}
-
-
-
-
- )
-}
-
-function ScrollBar({
className,
- orientation = "vertical",
- ...props
-}: React.ComponentProps) {
+ x = "hidden",
+ y = "scroll",
+ onScroll,
+ ref,
+}: ScrollAreaProps) {
+ const options = useMemo(
+ () => ({
+ ...BASE_OPTIONS,
+ overflow: { x, y },
+ }),
+ [x, y]
+ )
+
+ const events = useMemo(
+ () => (onScroll ? { scroll: (_instance, event) => onScroll(event) } : {}),
+ [onScroll]
+ )
+
return (
-
-
-
+ {children}
+
)
}
-
-export { ScrollArea, ScrollBar }