🐛fix: make three dots default show 3 dots and can trigger with right click (#5712)

* 🐛fix: default show 3 dots

* enhancement: enable resizable left panel (#5713)

* enhancement: enable resizable left panel

* Update web-app/src/hooks/useLeftPanel.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Faisal Amir 2025-07-07 11:14:43 +07:00 committed by GitHub
parent dc4e592de9
commit 1422d94fac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 191 additions and 28 deletions

View File

@ -56,6 +56,7 @@
"react-i18next": "^15.5.1", "react-i18next": "^15.5.1",
"react-joyride": "^2.9.3", "react-joyride": "^2.9.3",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.3",
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
"react-syntax-highlighter-virtualized-renderer": "^1.1.0", "react-syntax-highlighter-virtualized-renderer": "^1.1.0",
"react-textarea-autosize": "^8.5.9", "react-textarea-autosize": "^8.5.9",

View File

@ -0,0 +1,54 @@
import * as React from 'react'
import { GripVerticalIcon } from 'lucide-react'
import * as ResizablePrimitive from 'react-resizable-panels'
import { cn } from '@/lib/utils'
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className
)}
{...props}
>
{withHandle && (
<div className="bg-main-view z-10 flex h-4 w-3 items-center justify-center rounded-xs border border-main-view-fg/10 relative left-0.5">
<GripVerticalIcon className="size-2.5 text-main-view-fg" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@ -182,7 +182,9 @@ export function DownloadManagement() {
getProviders().then(setProviders) getProviders().then(setProviders)
toast.success(t('common:toast.downloadComplete.title'), { toast.success(t('common:toast.downloadComplete.title'), {
id: 'download-complete', id: 'download-complete',
description: t('common:toast.downloadComplete.description', { modelId: state.modelId }), description: t('common:toast.downloadComplete.description', {
modelId: state.modelId,
}),
}) })
}, },
[removeDownload, removeLocalDownloadingModel, setProviders, t] [removeDownload, removeLocalDownloadingModel, setProviders, t]
@ -237,10 +239,14 @@ export function DownloadManagement() {
<PopoverTrigger asChild> <PopoverTrigger asChild>
{isLeftPanelOpen ? ( {isLeftPanelOpen ? (
<div className="bg-left-panel-fg/10 hover:bg-left-panel-fg/12 p-2 rounded-md my-1 relative border border-left-panel-fg/10 cursor-pointer text-left"> <div className="bg-left-panel-fg/10 hover:bg-left-panel-fg/12 p-2 rounded-md my-1 relative border border-left-panel-fg/10 cursor-pointer text-left">
<div className="bg-primary font-bold size-5 rounded-full absolute -top-2 -right-1 flex items-center justify-center text-primary-fg"> <div className="text-left-panel-fg/80 font-medium flex gap-2">
<span>{t('downloads')}</span>
<span>
<div className="bg-primary font-bold size-5 rounded-full flex items-center justify-center text-primary-fg">
{downloadCount} {downloadCount}
</div> </div>
<p className="text-left-panel-fg/80 font-medium">{t('downloads')}</p> </span>
</div>
<div className="mt-2 flex items-center justify-between space-x-2"> <div className="mt-2 flex items-center justify-between space-x-2">
<Progress value={overallProgress * 100} /> <Progress value={overallProgress * 100} />
<span className="text-xs font-medium text-left-panel-fg/80 shrink-0"> <span className="text-xs font-medium text-left-panel-fg/80 shrink-0">
@ -272,7 +278,9 @@ export function DownloadManagement() {
> >
<div className="flex flex-col"> <div className="flex flex-col">
<div className="p-2 py-1.5 bg-main-view-fg/5 border-b border-main-view-fg/6"> <div className="p-2 py-1.5 bg-main-view-fg/5 border-b border-main-view-fg/6">
<p className="text-xs text-main-view-fg/70">{t('downloading')}</p> <p className="text-xs text-main-view-fg/70">
{t('downloading')}
</p>
</div> </div>
<div className="p-2 max-h-[300px] overflow-y-auto space-y-2"> <div className="p-2 max-h-[300px] overflow-y-auto space-y-2">
{appUpdateState.isDownloading && ( {appUpdateState.isDownloading && (
@ -309,10 +317,15 @@ export function DownloadManagement() {
title="Cancel download" title="Cancel download"
onClick={() => { onClick={() => {
abortDownload(download.name).then(() => { abortDownload(download.name).then(() => {
toast.info(t('common:toast.downloadCancelled.title'), { toast.info(
t('common:toast.downloadCancelled.title'),
{
id: 'cancel-download', id: 'cancel-download',
description: t('common:toast.downloadCancelled.description'), description: t(
}) 'common:toast.downloadCancelled.description'
),
}
)
if (downloadProcesses.length === 0) { if (downloadProcesses.length === 0) {
setIsPopoverOpen(false) setIsPopoverOpen(false)
} }

View File

@ -79,6 +79,9 @@ const LeftPanel = () => {
const searchContainerRef = useRef<HTMLDivElement>(null) const searchContainerRef = useRef<HTMLDivElement>(null)
const searchContainerMacRef = useRef<HTMLDivElement>(null) const searchContainerMacRef = useRef<HTMLDivElement>(null)
// Determine if we're in a resizable context (large screen with panel open)
const isResizableContext = !isSmallScreen && open
// Use click outside hook for panel with debugging // Use click outside hook for panel with debugging
useClickOutside( useClickOutside(
() => { () => {
@ -189,9 +192,17 @@ const LeftPanel = () => {
<aside <aside
ref={panelRef} ref={panelRef}
className={cn( className={cn(
'w-48 shrink-0 rounded-lg m-1.5 mr-0 text-left-panel-fg overflow-hidden', 'text-left-panel-fg overflow-hidden',
// Resizable context: full height and width, no margins
isResizableContext && 'h-full w-full',
// Small screen context: fixed positioning and styling
isSmallScreen && isSmallScreen &&
'fixed h-[calc(100%-16px)] bg-main-view z-40 rounded-sm border border-left-panel-fg/10 m-2 px-1', 'fixed h-[calc(100%-16px)] bg-main-view z-40 rounded-sm border border-left-panel-fg/10 m-2 px-1 w-48',
// Default context: original styling
!isResizableContext &&
!isSmallScreen &&
'w-48 shrink-0 rounded-lg m-1.5 mr-0',
// Visibility controls
open open
? 'opacity-100 visibility-visible' ? 'opacity-100 visibility-visible'
: 'w-0 absolute -top-100 -left-100 visibility-hidden' : 'w-0 absolute -top-100 -left-100 visibility-hidden'
@ -209,7 +220,12 @@ const LeftPanel = () => {
{!IS_MACOS && ( {!IS_MACOS && (
<div <div
ref={searchContainerRef} ref={searchContainerRef}
className="relative top-1.5 mb-4 mx-1 mt-1 w-[calc(100%-32px)] z-50" className={cn(
'relative top-1.5 mb-4 mt-1 z-50',
isResizableContext
? 'mx-2 w-[calc(100%-48px)]'
: 'mx-1 w-[calc(100%-32px)]'
)}
data-ignore-outside-clicks data-ignore-outside-clicks
> >
<IconSearch className="absolute size-4 top-1/2 left-2 -translate-y-1/2 text-left-panel-fg/50" /> <IconSearch className="absolute size-4 top-1/2 left-2 -translate-y-1/2 text-left-panel-fg/50" />
@ -241,7 +257,10 @@ const LeftPanel = () => {
{IS_MACOS && ( {IS_MACOS && (
<div <div
ref={searchContainerMacRef} ref={searchContainerMacRef}
className="relative mb-4 mx-1 mt-1" className={cn(
'relative mb-4 mt-1',
isResizableContext ? 'mx-2' : 'mx-1'
)}
data-ignore-outside-clicks data-ignore-outside-clicks
> >
<IconSearch className="absolute size-4 top-1/2 left-2 -translate-y-1/2 text-left-panel-fg/50" /> <IconSearch className="absolute size-4 top-1/2 left-2 -translate-y-1/2 text-left-panel-fg/50" />

View File

@ -105,8 +105,13 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => {
{...attributes} {...attributes}
{...listeners} {...listeners}
onClick={handleClick} onClick={handleClick}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
setOpenDropdown(true)
}}
className={cn( className={cn(
'mb-1 rounded hover:bg-left-panel-fg/10 flex items-center justify-between gap-2 px-1.5 group/thread-list transition-all', 'mb-1 rounded hover:bg-left-panel-fg/10 flex items-center justify-between gap-2 px-1.5 transition-all',
isDragging ? 'cursor-move' : 'cursor-pointer', isDragging ? 'cursor-move' : 'cursor-pointer',
isActive && 'bg-left-panel-fg/10' isActive && 'bg-left-panel-fg/10'
)} )}
@ -122,7 +127,7 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<IconDots <IconDots
size={14} size={14}
className="text-left-panel-fg/60 shrink-0 cursor-pointer px-0.5 -mr-1 data-[state=open]:bg-left-panel-fg/10 rounded group-hover/thread-list:data-[state=closed]:size-5 size-5 data-[state=closed]:size-0" className="text-left-panel-fg/60 shrink-0 cursor-pointer px-0.5 -mr-1 data-[state=open]:bg-left-panel-fg/10 rounded size-5"
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()

View File

@ -4,14 +4,18 @@ import { localStorageKey } from '@/constants/localStorage'
type LeftPanelStoreState = { type LeftPanelStoreState = {
open: boolean open: boolean
size: number
setLeftPanel: (value: boolean) => void setLeftPanel: (value: boolean) => void
setLeftPanelSize: (value: number) => void
} }
export const useLeftPanel = create<LeftPanelStoreState>()( export const useLeftPanel = create<LeftPanelStoreState>()(
persist( persist(
(set) => ({ (set) => ({
open: true, open: true,
size: 20, // Default size of 20%
setLeftPanel: (value) => set({ open: value }), setLeftPanel: (value) => set({ open: value }),
setLeftPanelSize: (value) => set({ size: value }),
}), }),
{ {
name: localStorageKey.LeftPanel, name: localStorageKey.LeftPanel,

View File

@ -20,6 +20,13 @@ import { cn } from '@/lib/utils'
import ToolApproval from '@/containers/dialogs/ToolApproval' import ToolApproval from '@/containers/dialogs/ToolApproval'
import { TranslationProvider } from '@/i18n/TranslationContext' import { TranslationProvider } from '@/i18n/TranslationContext'
import OutOfContextPromiseModal from '@/containers/dialogs/OutOfContextDialog' import OutOfContextPromiseModal from '@/containers/dialogs/OutOfContextDialog'
import { useSmallScreen } from '@/hooks/useMediaQuery'
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from '@/components/ui/resizable'
import { useCallback } from 'react'
export const Route = createRootRoute({ export const Route = createRootRoute({
component: RootLayout, component: RootLayout,
@ -27,7 +34,33 @@ export const Route = createRootRoute({
const AppLayout = () => { const AppLayout = () => {
const { productAnalyticPrompt } = useAnalytic() const { productAnalyticPrompt } = useAnalytic()
const { open: isLeftPanelOpen } = useLeftPanel() const {
open: isLeftPanelOpen,
setLeftPanel,
size: leftPanelSize,
setLeftPanelSize,
} = useLeftPanel()
const isSmallScreen = useSmallScreen()
// Minimum width threshold for auto-close (10% of screen width)
const MIN_PANEL_WIDTH_THRESHOLD = 14
// Handle panel size changes
const handlePanelLayout = useCallback(
(sizes: number[]) => {
if (sizes.length > 0) {
const newSize = sizes[0]
// Close panel if resized below minimum threshold
if (newSize < MIN_PANEL_WIDTH_THRESHOLD) {
setLeftPanel(false)
} else {
setLeftPanelSize(newSize)
}
}
},
[setLeftPanelSize, setLeftPanel]
)
return ( return (
<Fragment> <Fragment>
@ -37,6 +70,39 @@ const AppLayout = () => {
{/* Fake absolute panel top to enable window drag */} {/* Fake absolute panel top to enable window drag */}
<div className="absolute w-full h-10 z-10" data-tauri-drag-region /> <div className="absolute w-full h-10 z-10" data-tauri-drag-region />
<DialogAppUpdater /> <DialogAppUpdater />
{/* Use ResizablePanelGroup only on larger screens */}
{!isSmallScreen && isLeftPanelOpen ? (
<ResizablePanelGroup
direction="horizontal"
className="h-full"
onLayout={handlePanelLayout}
>
{/* Left Panel */}
<ResizablePanel
defaultSize={leftPanelSize}
minSize={MIN_PANEL_WIDTH_THRESHOLD}
maxSize={40}
collapsible
>
<div className="h-full p-1">
<LeftPanel />
</div>
</ResizablePanel>
{/* Resize Handle */}
<ResizableHandle withHandle />
{/* Main Content Panel */}
<ResizablePanel defaultSize={100 - leftPanelSize} minSize={60}>
<div className="h-full p-1 pl-0">
<div className="bg-main-view text-main-view-fg border border-main-view-fg/5 w-full h-full rounded-lg overflow-hidden">
<Outlet />
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
) : (
<div className="flex h-full"> <div className="flex h-full">
{/* left content panel - only show if not logs route */} {/* left content panel - only show if not logs route */}
<LeftPanel /> <LeftPanel />
@ -53,6 +119,7 @@ const AppLayout = () => {
</div> </div>
</div> </div>
</div> </div>
)}
</main> </main>
{productAnalyticPrompt && <PromptAnalytic />} {productAnalyticPrompt && <PromptAnalytic />}
</Fragment> </Fragment>