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

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