feat(frontend): replace native scrollbar styling with OverlayScrollbars

Adopt OverlayScrollbars for cross-platform consistent overlay scrollbars
with auto-hide on pointer leave, hover grow effect, and click-to-scroll.

- Add overlayscrollbars + overlayscrollbars-react dependencies
- Rewrite ScrollArea component from Radix to OverlayScrollbars wrapper
- Define custom theme `os-theme-codeg` in globals.css (6px → 8px on hover)
- Initialize body-level overlay scrollbar via OverlayScrollbarsInit
- Migrate all scrollbar-thin / scrollbar-thin-edge usages to ScrollArea
- Keep native .scrollbar-thin fallback for virtua scroll containers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-12 20:16:46 +08:00
parent ad50cc28fc
commit 0fafe782ee
13 changed files with 411 additions and 336 deletions

View File

@@ -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",

22
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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;

View File

@@ -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
>
<AppearanceProvider>{children}</AppearanceProvider>
<AppearanceProvider>
<OverlayScrollbarsInit />
{children}
</AppearanceProvider>
</ThemeProvider>
</AppI18nProvider>
</NextIntlClientProvider>

View File

@@ -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<HTMLDivElement>(null)
const scrollToActiveRef = useRef<() => void>(() => {})
const pendingScrollRef = useRef(false)
const virtualizerRef = useRef<VirtualizerHandle>(null)
@@ -479,12 +478,8 @@ export function SidebarConversationList({
) : (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
ref={scrollContainerRef}
className={cn(
"flex-1 min-h-0 overflow-y-auto scrollbar-thin px-2",
"[overflow-anchor:none]"
)}
<ScrollArea
className={cn("flex-1 min-h-0 px-2", "[overflow-anchor:none]")}
>
<Virtualizer ref={virtualizerRef} itemSize={CARD_HEIGHT}>
{flatItems.map((item) => {
@@ -537,7 +532,7 @@ export function SidebarConversationList({
)
})}
</Virtualizer>
</div>
</ScrollArea>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={handleNewConversation}>

View File

@@ -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 (
<div className={cn("h-full overflow-auto scrollbar-thin", className)}>
<ScrollArea className={cn("h-full", className)} x="scroll">
<pre className="font-mono text-[11px] leading-5 whitespace-pre-wrap text-muted-foreground p-3">
{diffText}
</pre>
</div>
</ScrollArea>
)
}
return (
<div className={cn("h-full overflow-auto scrollbar-thin", className)}>
<ScrollArea className={cn("h-full", className)} x="scroll">
<div className="space-y-3">
{files.map((file) => {
const newFile = isNewFileOnly(file)
@@ -586,7 +587,7 @@ export function UnifiedDiffPreview({
)}
</header>
<div className="overflow-auto scrollbar-thin">
<ScrollArea x="scroll">
<div className="inline-block min-w-full">
{newFile
? file.hunks.map((hunk) => (
@@ -599,11 +600,11 @@ export function UnifiedDiffPreview({
</div>
))}
</div>
</div>
</ScrollArea>
</section>
)
})}
</div>
</div>
</ScrollArea>
)
}

View File

@@ -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({
</p>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin">
<ScrollArea className="flex-1 min-h-0">
<div className="py-1">
{diffOutline.files.map((file) => (
<ContextMenu key={file.key}>
@@ -743,7 +744,7 @@ function DiffFileList({
</ContextMenu>
))}
</div>
</div>
</ScrollArea>
</div>
)
}

View File

@@ -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() {
<div className="flex flex-col h-full">
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex-1 min-h-0 overflow-auto pb-1 scrollbar-thin-edge">
<ScrollArea className="flex-1 min-h-0 pb-1" x="scroll">
<FileTree
key={folder?.path ?? "file-tree-empty"}
className="border-0 rounded-none bg-transparent w-max min-w-full"
@@ -2319,7 +2320,7 @@ export function FileTreeTab() {
</ContextMenu>
)}
</FileTree>
</div>
</ScrollArea>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub>

View File

@@ -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 (
<>
<div className="h-full min-h-0 overflow-y-auto scrollbar-thin-edge">
<ScrollArea className="h-full min-h-0" x="scroll">
{trackedChanges.length === 0 && untrackedChanges.length === 0 ? (
<div className="flex items-center justify-center h-full p-4">
<p className="text-xs text-muted-foreground text-center">
@@ -1506,7 +1507,7 @@ export function GitChangesTab() {
)}
</div>
)}
</div>
</ScrollArea>
<Dialog
open={Boolean(directoryGitActionType && directoryGitActionTarget)}

View File

@@ -2,7 +2,6 @@
import {
type ReactElement,
type UIEvent,
useCallback,
useEffect,
useMemo,
@@ -89,6 +88,7 @@ import {
import type { GitBranchList, GitLogEntry, GitLogFileChange } from "@/lib/types"
import { toast } from "sonner"
import { toErrorMessage } from "@/lib/app-error"
import { ScrollArea } from "@/components/ui/scroll-area"
function formatRelativeTime(
dateStr: string,
@@ -896,14 +896,15 @@ export function GitLogTab() {
}
}, [folder, refreshBranches, fetchLog])
const handleScroll = useCallback((e: UIEvent<HTMLDivElement>) => {
const nextScrolled = e.currentTarget.scrollTop > 0
const handleScroll = useCallback((e: Event) => {
const target = e.target as HTMLElement
const nextScrolled = target.scrollTop > 0
setScrolled((prev) => (prev === nextScrolled ? prev : nextScrolled))
}, [])
if (loading) {
return (
<div className="flex flex-col h-full overflow-y-auto scrollbar-thin px-3 py-3">
<ScrollArea className="h-full px-3 py-3">
{hasBranches && (
<BranchSelector
branchList={branchList}
@@ -923,13 +924,13 @@ export function GitLogTab() {
</div>
))}
</div>
</div>
</ScrollArea>
)
}
if (error) {
return (
<div className="flex flex-col h-full overflow-y-auto scrollbar-thin px-3 py-3">
<ScrollArea className="h-full px-3 py-3">
{hasBranches && (
<BranchSelector
branchList={branchList}
@@ -953,29 +954,31 @@ export function GitLogTab() {
{t("retry")}
</Button>
</div>
</div>
</ScrollArea>
)
}
if (entries.length === 0) {
return (
<div className="flex flex-col h-full overflow-y-auto scrollbar-thin px-3 py-3">
{hasBranches && (
<BranchSelector
branchList={branchList}
currentBranch={currentBranch}
selectedBranch={selectedBranch}
onBranchChange={handleBranchChange}
onRefresh={handleRefresh}
refreshing={loading || refreshing}
/>
)}
<div className="flex items-center justify-center flex-1 p-4">
<p className="text-xs text-muted-foreground text-center">
{t("noCommitsFound")}
</p>
<ScrollArea className="h-full px-3 py-3">
<div className="flex flex-col min-h-full">
{hasBranches && (
<BranchSelector
branchList={branchList}
currentBranch={currentBranch}
selectedBranch={selectedBranch}
onBranchChange={handleBranchChange}
onRefresh={handleRefresh}
refreshing={loading || refreshing}
/>
)}
<div className="flex items-center justify-center flex-1 p-4">
<p className="text-xs text-muted-foreground text-center">
{t("noCommitsFound")}
</p>
</div>
</div>
</div>
</ScrollArea>
)
}
@@ -983,256 +986,258 @@ export function GitLogTab() {
<div className="flex flex-col h-full">
<ContextMenu>
<ContextMenuTrigger asChild>
<div
<ScrollArea
onScroll={handleScroll}
className="flex-1 min-h-0 overflow-y-auto scrollbar-thin px-3 py-3 space-y-3"
className="flex-1 min-h-0 px-3 py-3"
>
{hasBranches && (
<div
className={`sticky top-0 z-10 rounded-full bg-sidebar/85 supports-[backdrop-filter]:bg-sidebar/70 backdrop-blur ${scrolled ? "p-2 shadow-md" : "p-0"}`}
>
<BranchSelector
branchList={branchList}
currentBranch={currentBranch}
selectedBranch={selectedBranch}
onBranchChange={handleBranchChange}
onRefresh={handleRefresh}
refreshing={loading || refreshing}
/>
</div>
)}
{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]
<div className="space-y-3">
{hasBranches && (
<div
className={`sticky top-0 z-10 rounded-full bg-sidebar/85 supports-[backdrop-filter]:bg-sidebar/70 backdrop-blur ${scrolled ? "p-2 shadow-md" : "p-0"}`}
>
<BranchSelector
branchList={branchList}
currentBranch={currentBranch}
selectedBranch={selectedBranch}
onBranchChange={handleBranchChange}
onRefresh={handleRefresh}
refreshing={loading || refreshing}
/>
</div>
)}
{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 (
<ContextMenu key={entry.full_hash}>
<ContextMenuTrigger asChild>
<div>
<Commit
onOpenChange={(open) => {
setOpenByCommit((prev) => ({
...prev,
[commitKey]: open,
}))
if (open) {
void fetchCommitBranches(commitKey)
}
}}
open={isOpen}
>
<CommitHeader>
<CommitInfo className="min-w-0">
<CommitMessage className="line-clamp-1 leading-snug">
{entry.message}
</CommitMessage>
<CommitMetadata className="mt-1 min-w-0 flex items-center gap-1.5">
<span
className="inline-flex shrink-0"
title={pushStatus.label}
aria-label={pushStatus.label}
>
<PushStatusIcon
className={pushStatus.className}
size={12}
/>
</span>
<span className="truncate">{entry.author}</span>
<CommitTimestamp
className="shrink-0"
date={commitDate ?? new Date()}
>
{formatRelativeTime(entry.date, t)}
</CommitTimestamp>
<CommitHash className="text-primary/70">
{entry.hash}
</CommitHash>
</CommitMetadata>
</CommitInfo>
<CommitActions className="shrink-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
onClick={() => {
void openCommitDiff(
entry.full_hash,
undefined,
entry.message
)
}}
title={tCommon("viewDiff")}
aria-label={t("viewCommitDiffAria", {
hash: entry.hash,
})}
>
<GitCompare size={14} />
</Button>
</CommitActions>
</CommitHeader>
<CommitContent>
<div className="space-y-3">
<div className="grid grid-cols-[4rem_minmax(0,1fr)] items-center gap-x-2 gap-y-1 text-xs">
<span className="text-muted-foreground">
{t("hash")}
</span>
<span className="group/hash flex items-center gap-1 min-w-0">
<code
className="block min-w-0 flex-1 truncate font-mono"
title={entry.full_hash}
>
{entry.full_hash}
</code>
<CommitCopyButton
aria-label={t("copyFullCommitHashAria", {
hash: entry.full_hash,
})}
className="size-5 shrink-0 opacity-0 transition-opacity group-hover/hash:opacity-100 group-focus-within/hash:opacity-100"
hash={entry.full_hash}
title={t("copyHash")}
/>
</span>
<span className="text-muted-foreground">
{t("author")}
</span>
<span className="min-w-0 flex items-center gap-1">
<span className="min-w-0 truncate">
{entry.author}
</span>
<span className="shrink-0 text-muted-foreground">
·
</span>
<time
className="shrink-0"
dateTime={commitDate?.toISOString()}
>
{commitDate
? commitDate.toLocaleString()
: entry.date}
</time>
</span>
</div>
<div className="group/msg relative rounded-lg border border-border/60 bg-muted/20 p-2.5">
<p className="text-xs whitespace-pre-wrap break-words pr-6">
return (
<ContextMenu key={entry.full_hash}>
<ContextMenuTrigger asChild>
<div>
<Commit
onOpenChange={(open) => {
setOpenByCommit((prev) => ({
...prev,
[commitKey]: open,
}))
if (open) {
void fetchCommitBranches(commitKey)
}
}}
open={isOpen}
>
<CommitHeader>
<CommitInfo className="min-w-0">
<CommitMessage className="line-clamp-1 leading-snug">
{entry.message}
</p>
<CommitCopyButton
className="absolute top-1.5 right-1.5 size-5 opacity-0 transition-opacity group-hover/msg:opacity-100 group-focus-within/msg:opacity-100"
hash={entry.message}
title={t("copyMessage")}
/>
</div>
{entry.files.length === 0 ? (
<div className="space-y-1">
<p className="text-[11px] text-muted-foreground">
{t("filesTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("noFileChangeDetails")}
</p>
</CommitMessage>
<CommitMetadata className="mt-1 min-w-0 flex items-center gap-1.5">
<span
className="inline-flex shrink-0"
title={pushStatus.label}
aria-label={pushStatus.label}
>
<PushStatusIcon
className={pushStatus.className}
size={12}
/>
</span>
<span className="truncate">{entry.author}</span>
<CommitTimestamp
className="shrink-0"
date={commitDate ?? new Date()}
>
{formatRelativeTime(entry.date, t)}
</CommitTimestamp>
<CommitHash className="text-primary/70">
{entry.hash}
</CommitHash>
</CommitMetadata>
</CommitInfo>
<CommitActions className="shrink-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
onClick={() => {
void openCommitDiff(
entry.full_hash,
undefined,
entry.message
)
}}
title={tCommon("viewDiff")}
aria-label={t("viewCommitDiffAria", {
hash: entry.hash,
})}
>
<GitCompare size={14} />
</Button>
</CommitActions>
</CommitHeader>
<CommitContent>
<div className="space-y-3">
<div className="grid grid-cols-[4rem_minmax(0,1fr)] items-center gap-x-2 gap-y-1 text-xs">
<span className="text-muted-foreground">
{t("hash")}
</span>
<span className="group/hash flex items-center gap-1 min-w-0">
<code
className="block min-w-0 flex-1 truncate font-mono"
title={entry.full_hash}
>
{entry.full_hash}
</code>
<CommitCopyButton
aria-label={t("copyFullCommitHashAria", {
hash: entry.full_hash,
})}
className="size-5 shrink-0 opacity-0 transition-opacity group-hover/hash:opacity-100 group-focus-within/hash:opacity-100"
hash={entry.full_hash}
title={t("copyHash")}
/>
</span>
<span className="text-muted-foreground">
{t("author")}
</span>
<span className="min-w-0 flex items-center gap-1">
<span className="min-w-0 truncate">
{entry.author}
</span>
<span className="shrink-0 text-muted-foreground">
·
</span>
<time
className="shrink-0"
dateTime={commitDate?.toISOString()}
>
{commitDate
? commitDate.toLocaleString()
: entry.date}
</time>
</span>
</div>
) : (
<CommitFilesTree
commitHash={entry.full_hash}
files={entry.files}
folderName={folderName}
onOpenCommitDiff={openCommitDiff}
onOpenFilePreview={openFilePreview}
/>
)}
<div className="pt-3 space-y-1">
<p className="text-[11px] text-muted-foreground">
{t("branchesTitle")}
</p>
{isBranchLoading ? (
<p className="text-xs text-muted-foreground">
{t("loadingBranches")}
<div className="group/msg relative rounded-lg border border-border/60 bg-muted/20 p-2.5">
<p className="text-xs whitespace-pre-wrap break-words pr-6">
{entry.message}
</p>
) : branchError ? (
<p className="text-xs text-destructive">
{branchError}
</p>
) : commitBranches &&
commitBranches.length > 0 ? (
<div className="flex flex-wrap gap-1">
{commitBranches.map((branch) => (
<span
key={`${commitKey}-${branch}`}
className="rounded-md border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground"
title={branch}
>
{branch}
</span>
))}
<CommitCopyButton
className="absolute top-1.5 right-1.5 size-5 opacity-0 transition-opacity group-hover/msg:opacity-100 group-focus-within/msg:opacity-100"
hash={entry.message}
title={t("copyMessage")}
/>
</div>
{entry.files.length === 0 ? (
<div className="space-y-1">
<p className="text-[11px] text-muted-foreground">
{t("filesTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("noFileChangeDetails")}
</p>
</div>
) : (
<p className="text-xs text-muted-foreground">
{t("noContainingBranches")}
</p>
<CommitFilesTree
commitHash={entry.full_hash}
files={entry.files}
folderName={folderName}
onOpenCommitDiff={openCommitDiff}
onOpenFilePreview={openFilePreview}
/>
)}
<div className="pt-3 space-y-1">
<p className="text-[11px] text-muted-foreground">
{t("branchesTitle")}
</p>
{isBranchLoading ? (
<p className="text-xs text-muted-foreground">
{t("loadingBranches")}
</p>
) : branchError ? (
<p className="text-xs text-destructive">
{branchError}
</p>
) : commitBranches &&
commitBranches.length > 0 ? (
<div className="flex flex-wrap gap-1">
{commitBranches.map((branch) => (
<span
key={`${commitKey}-${branch}`}
className="rounded-md border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground"
title={branch}
>
{branch}
</span>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
{t("noContainingBranches")}
</p>
)}
</div>
</div>
</div>
</CommitContent>
</Commit>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onSelect={() => {
handleOpenNewBranchDialog(entry)
}}
>
<GitBranchPlus className="h-3.5 w-3.5" />
{t("newBranch")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
void openCommitDiff(
entry.full_hash,
undefined,
entry.message
)
}}
>
<GitCompare className="h-3.5 w-3.5" />
{tCommon("viewDiff")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
void fetchLog()
}}
>
<RefreshCw className="size-3.5" />
{tCommon("refresh")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
if (!folder) return
openPushWindow(folder.id).catch((err) => {
const msg = toErrorMessage(err)
toast.error(t("toasts.openPushWindowFailed"), {
description: msg,
</CommitContent>
</Commit>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onSelect={() => {
handleOpenNewBranchDialog(entry)
}}
>
<GitBranchPlus className="h-3.5 w-3.5" />
{t("newBranch")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
void openCommitDiff(
entry.full_hash,
undefined,
entry.message
)
}}
>
<GitCompare className="h-3.5 w-3.5" />
{tCommon("viewDiff")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
void fetchLog()
}}
>
<RefreshCw className="size-3.5" />
{tCommon("refresh")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
if (!folder) return
openPushWindow(folder.id).catch((err) => {
const msg = toErrorMessage(err)
toast.error(t("toasts.openPushWindowFailed"), {
description: msg,
})
})
})
}}
>
<Upload className="size-3.5" />
{tCommon("push")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
</div>
}}
>
<Upload className="size-3.5" />
{tCommon("push")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
</div>
</ScrollArea>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem

View File

@@ -18,6 +18,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { ScrollArea } from "@/components/ui/scroll-area"
import { cn } from "@/lib/utils"
function isRemovedFileDiff(diff: string | null): boolean {
@@ -290,9 +291,9 @@ export function SessionFilesTab() {
return (
<div className="flex flex-col h-full">
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin px-2">
<ScrollArea className="flex-1 min-h-0 px-2">
<SessionFilesContent conversationId={conversationId} />
</div>
</ScrollArea>
</div>
)
}

View File

@@ -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
}

View File

@@ -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<OverlayScrollbarsComponentRef>
}
function ScrollArea({
className,
const BASE_OPTIONS: OverlayScrollbarsComponentProps["options"] = {
scrollbars: {
theme: "os-theme-codeg",
autoHide: "leave",
clickScroll: true,
},
}
export function ScrollArea({
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
x = "hidden",
y = "scroll",
onScroll,
ref,
}: ScrollAreaProps) {
const options = useMemo<OverlayScrollbarsComponentProps["options"]>(
() => ({
...BASE_OPTIONS,
overflow: { x, y },
}),
[x, y]
)
const events = useMemo<OverlayScrollbarsComponentProps["events"]>(
() => (onScroll ? { scroll: (_instance, event) => onScroll(event) } : {}),
[onScroll]
)
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none",
className
)}
{...props}
<OverlayScrollbarsComponent
ref={ref}
className={className}
options={options}
events={events}
defer
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="rounded-full bg-border relative flex-1"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
{children}
</OverlayScrollbarsComponent>
)
}
export { ScrollArea, ScrollBar }