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..59cfb08 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,6 +2,7 @@ @import "tw-animate-css"; @import "shadcn/tailwind.css"; @import "@xterm/xterm/css/xterm.css"; +@import "overlayscrollbars/overlayscrollbars.css"; @custom-variant dark (&:is(.dark *)); @@ -1002,14 +1003,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 ( -
+
           {diffText}
         
-
+ ) } 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() { )}

)} -
+ ) => { - 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 ( -
+ {hasBranches && ( ))}
-
+ ) } 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..9df4608 --- /dev/null +++ b/src/components/overlay-scrollbars-init.tsx @@ -0,0 +1,24 @@ +"use client" + +import { useEffect } from "react" +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 }