mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-03 12:27:41 +08:00
Merge remote-tracking branch 'origin/main' into codex/office-suite-parse-preview
This commit is contained in:
@@ -172,6 +172,8 @@ export const COMPLEX_PREFERENCE_MAPPINGS: ComplexMapping[] = [
|
||||
transform: (sources) => {
|
||||
const rewrite = (arr: unknown): unknown[] | undefined =>
|
||||
Array.isArray(arr) ? arr.map((v) => (v === 'minapp' ? 'mini_app' : v)) : undefined
|
||||
const toSidebarFavorites = (arr: unknown[] | undefined): Array<{ type: 'app'; id: string }> | undefined =>
|
||||
arr?.filter((item): item is string => typeof item === 'string').map((id) => ({ type: 'app', id }))
|
||||
const addAgents = (visible: unknown[] | undefined, invisible: unknown[] | undefined): unknown[] | undefined => {
|
||||
if (!visible || visible.includes('agents')) {
|
||||
return visible
|
||||
@@ -190,7 +192,7 @@ export const COMPLEX_PREFERENCE_MAPPINGS: ComplexMapping[] = [
|
||||
const invisible = rewrite(sources.disabled)
|
||||
const visibleWithAgents = dedup(addAgents(visible, invisible))
|
||||
return {
|
||||
'ui.sidebar.favorites': visibleWithAgents
|
||||
'ui.sidebar.favorites': toSidebarFavorites(visibleWithAgents)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
type TransformResult
|
||||
} from '../ComplexPreferenceMappings'
|
||||
|
||||
const appFavorite = (id: string) => ({ type: 'app', id })
|
||||
|
||||
describe('ComplexPreferenceMappings', () => {
|
||||
describe('type exports', () => {
|
||||
it('should export SourceDefinition type', () => {
|
||||
@@ -181,7 +183,12 @@ describe('ComplexPreferenceMappings', () => {
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
'ui.sidebar.favorites': ['assistants', 'agents', 'mini_app', 'translate']
|
||||
'ui.sidebar.favorites': [
|
||||
appFavorite('assistants'),
|
||||
appFavorite('agents'),
|
||||
appFavorite('mini_app'),
|
||||
appFavorite('translate')
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
@@ -193,7 +200,12 @@ describe('ComplexPreferenceMappings', () => {
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
'ui.sidebar.favorites': ['assistants', 'agents', 'translate', 'paintings']
|
||||
'ui.sidebar.favorites': [
|
||||
appFavorite('assistants'),
|
||||
appFavorite('agents'),
|
||||
appFavorite('translate'),
|
||||
appFavorite('paintings')
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
@@ -205,7 +217,7 @@ describe('ComplexPreferenceMappings', () => {
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
'ui.sidebar.favorites': ['store', 'translate', 'agents']
|
||||
'ui.sidebar.favorites': [appFavorite('store'), appFavorite('translate'), appFavorite('agents')]
|
||||
})
|
||||
})
|
||||
|
||||
@@ -217,7 +229,7 @@ describe('ComplexPreferenceMappings', () => {
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
'ui.sidebar.favorites': ['assistants', 'agents', 'translate']
|
||||
'ui.sidebar.favorites': [appFavorite('assistants'), appFavorite('agents'), appFavorite('translate')]
|
||||
})
|
||||
})
|
||||
|
||||
@@ -241,17 +253,17 @@ describe('ComplexPreferenceMappings', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
'ui.sidebar.favorites': [
|
||||
'assistants',
|
||||
'agents',
|
||||
'store',
|
||||
'paintings',
|
||||
'translate',
|
||||
'mini_app',
|
||||
'knowledge',
|
||||
'files',
|
||||
'code_tools',
|
||||
'notes',
|
||||
'openclaw'
|
||||
appFavorite('assistants'),
|
||||
appFavorite('agents'),
|
||||
appFavorite('store'),
|
||||
appFavorite('paintings'),
|
||||
appFavorite('translate'),
|
||||
appFavorite('mini_app'),
|
||||
appFavorite('knowledge'),
|
||||
appFavorite('files'),
|
||||
appFavorite('code_tools'),
|
||||
appFavorite('notes'),
|
||||
appFavorite('openclaw')
|
||||
]
|
||||
})
|
||||
})
|
||||
@@ -275,16 +287,16 @@ describe('ComplexPreferenceMappings', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
'ui.sidebar.favorites': [
|
||||
'assistants',
|
||||
'agents',
|
||||
'store',
|
||||
'paintings',
|
||||
'translate',
|
||||
'mini_app',
|
||||
'knowledge',
|
||||
'files',
|
||||
'code_tools',
|
||||
'notes'
|
||||
appFavorite('assistants'),
|
||||
appFavorite('agents'),
|
||||
appFavorite('store'),
|
||||
appFavorite('paintings'),
|
||||
appFavorite('translate'),
|
||||
appFavorite('mini_app'),
|
||||
appFavorite('knowledge'),
|
||||
appFavorite('files'),
|
||||
appFavorite('code_tools'),
|
||||
appFavorite('notes')
|
||||
]
|
||||
})
|
||||
})
|
||||
@@ -308,16 +320,16 @@ describe('ComplexPreferenceMappings', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
'ui.sidebar.favorites': [
|
||||
'assistants',
|
||||
'agents',
|
||||
'store',
|
||||
'paintings',
|
||||
'translate',
|
||||
'mini_app',
|
||||
'knowledge',
|
||||
'files',
|
||||
'code_tools',
|
||||
'notes'
|
||||
appFavorite('assistants'),
|
||||
appFavorite('agents'),
|
||||
appFavorite('store'),
|
||||
appFavorite('paintings'),
|
||||
appFavorite('translate'),
|
||||
appFavorite('mini_app'),
|
||||
appFavorite('knowledge'),
|
||||
appFavorite('files'),
|
||||
appFavorite('code_tools'),
|
||||
appFavorite('notes')
|
||||
]
|
||||
})
|
||||
})
|
||||
@@ -330,7 +342,7 @@ describe('ComplexPreferenceMappings', () => {
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
'ui.sidebar.favorites': ['assistants', 'translate']
|
||||
'ui.sidebar.favorites': [appFavorite('assistants'), appFavorite('translate')]
|
||||
})
|
||||
})
|
||||
|
||||
@@ -342,7 +354,12 @@ describe('ComplexPreferenceMappings', () => {
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
'ui.sidebar.favorites': ['assistants', 'agents', 'mini_app', 'translate']
|
||||
'ui.sidebar.favorites': [
|
||||
appFavorite('assistants'),
|
||||
appFavorite('agents'),
|
||||
appFavorite('mini_app'),
|
||||
appFavorite('translate')
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @eslint-react/dom/no-unsafe-iframe-sandbox -- HTML artifact capture needs contentDocument access. */
|
||||
import { memo, type Ref } from 'react'
|
||||
|
||||
export const HTML_PREVIEW_DEFAULT_BASE_URL = 'about:srcdoc'
|
||||
|
||||
@@ -30,7 +30,11 @@ const mocks = vi.hoisted(() => ({
|
||||
tabs: [] as Tab[],
|
||||
preferenceValues: {
|
||||
'app.user.name': 'JD',
|
||||
'ui.sidebar.favorites': ['assistants', 'agents', 'translate'],
|
||||
'ui.sidebar.favorites': [
|
||||
{ type: 'app', id: 'assistants' },
|
||||
{ type: 'app', id: 'agents' },
|
||||
{ type: 'app', id: 'translate' }
|
||||
],
|
||||
'feature.paintings.default_provider': 'zhipu'
|
||||
} as Record<string, unknown>,
|
||||
persistCacheValues: {
|
||||
@@ -566,7 +570,11 @@ describe('GlobalSearchPanel', () => {
|
||||
mocks.sessionMessageQueryResult = undefined
|
||||
mocks.preferenceValues = {
|
||||
'app.user.name': 'JD',
|
||||
'ui.sidebar.favorites': ['assistants', 'agents', 'translate'],
|
||||
'ui.sidebar.favorites': [
|
||||
{ type: 'app', id: 'assistants' },
|
||||
{ type: 'app', id: 'agents' },
|
||||
{ type: 'app', id: 'translate' }
|
||||
],
|
||||
'feature.paintings.default_provider': 'zhipu'
|
||||
}
|
||||
mocks.persistCacheValues = {
|
||||
|
||||
@@ -3,22 +3,23 @@ import type { MiniApp } from '@shared/data/types/miniApp'
|
||||
import type { FC } from 'react'
|
||||
|
||||
interface Props {
|
||||
app: MiniApp
|
||||
appearance?: 'avatar' | 'plain'
|
||||
sidebar?: boolean
|
||||
app: Pick<MiniApp, 'logo' | 'name' | 'background'>
|
||||
/** `avatar` keeps the bordered Avatar chrome; `plain` strips it from icon logos; `bare` also strips it from image logos. */
|
||||
appearance?: 'avatar' | 'plain' | 'bare'
|
||||
size?: number
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const MiniAppIcon: FC<Props> = ({ app, appearance = 'avatar', size = 48, style, sidebar = false }) => {
|
||||
const MiniAppIcon: FC<Props> = ({ app, appearance = 'avatar', size = 48, style }) => {
|
||||
// Preset-derived apps already include seeded display fields.
|
||||
if (app.logo) {
|
||||
const logo = getMiniAppsLogo(app.logo)
|
||||
const chromeless = appearance === 'plain' || appearance === 'bare'
|
||||
|
||||
// CompoundIcon: default usages keep the Avatar wrapper; Launchpad-style tiles render the logo itself.
|
||||
if (logo && typeof logo !== 'string') {
|
||||
const Icon = logo
|
||||
if (appearance === 'plain') {
|
||||
if (chromeless) {
|
||||
return (
|
||||
<span
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
@@ -40,6 +41,18 @@ const MiniAppIcon: FC<Props> = ({ app, appearance = 'avatar', size = 48, style,
|
||||
return <Icon.Avatar size={size} className="select-none border border-border" shape="rounded" />
|
||||
}
|
||||
|
||||
if (appearance === 'bare') {
|
||||
return (
|
||||
<img
|
||||
src={typeof logo === 'string' ? logo : app.logo}
|
||||
className="shrink-0 select-none object-contain"
|
||||
style={{ width: `${size}px`, height: `${size}px`, userSelect: 'none', ...style }}
|
||||
draggable={false}
|
||||
alt={app.name || 'MiniApp Icon'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={typeof logo === 'string' ? logo : app.logo}
|
||||
@@ -49,7 +62,6 @@ const MiniAppIcon: FC<Props> = ({ app, appearance = 'avatar', size = 48, style,
|
||||
height: `${size}px`,
|
||||
backgroundColor: app.background,
|
||||
userSelect: 'none',
|
||||
...(sidebar ? {} : undefined),
|
||||
...style
|
||||
}}
|
||||
draggable={false}
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('MiniAppIcon', () => {
|
||||
|
||||
it('should render correctly with various props', () => {
|
||||
const customStyle = { marginTop: '10px' }
|
||||
const { container } = render(<MiniAppIcon app={mockApp} size={64} style={customStyle} sidebar={true} />)
|
||||
const { container } = render(<MiniAppIcon app={mockApp} size={64} style={customStyle} />)
|
||||
|
||||
const img = container.querySelector('img')
|
||||
expect(img).toBeInTheDocument()
|
||||
@@ -63,16 +63,6 @@ describe('MiniAppIcon', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should not apply app.style when sidebar is true', () => {
|
||||
const { container } = render(<MiniAppIcon app={mockApp} sidebar={true} />)
|
||||
const img = container.querySelector('img')
|
||||
|
||||
expect(img).not.toHaveStyle({
|
||||
opacity: '0.8',
|
||||
transform: 'scale(1.1)'
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null when app is not found in allMiniApps', () => {
|
||||
const unknownApp = {
|
||||
appId: 'unknown-app' as any,
|
||||
|
||||
@@ -7,6 +7,7 @@ import IndicatorLight from '@renderer/components/IndicatorLight'
|
||||
import MarqueeText from '@renderer/components/MarqueeText'
|
||||
import { useTabs } from '@renderer/hooks/tab'
|
||||
import { useMiniApps } from '@renderer/hooks/useMiniApps'
|
||||
import { useSidebarFavorites } from '@renderer/hooks/useSidebarFavorites'
|
||||
import { ErrorCode, isDataApiError, toDataApiError } from '@shared/data/api'
|
||||
import type { MiniApp } from '@shared/data/types/miniApp'
|
||||
import type { FC, KeyboardEvent } from 'react'
|
||||
@@ -37,10 +38,12 @@ const MiniApp: FC<Props> = ({ app, onClick, onOpen, onEditCustom, size = 60, isL
|
||||
updateAppStatus,
|
||||
removeCustomMiniApp
|
||||
} = useMiniApps()
|
||||
const { miniAppFavoriteIds, toggleMiniApp } = useSidebarFavorites()
|
||||
const { openTab } = useTabs()
|
||||
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false)
|
||||
const [removingCustom, setRemovingCustom] = useState(false)
|
||||
const isPinned = pinned.some((p) => p.appId === app.appId)
|
||||
const isSidebarFavorite = miniAppFavoriteIds.includes(app.appId)
|
||||
const isVisible = miniApps.some((m) => m.appId === app.appId)
|
||||
// Pinned apps should always be visible regardless of region/locale filtering
|
||||
const shouldShow = isVisible || isPinned
|
||||
@@ -94,6 +97,10 @@ const MiniApp: FC<Props> = ({ app, onClick, onOpen, onEditCustom, size = 60, isL
|
||||
)
|
||||
}
|
||||
|
||||
const handleToggleSidebarFavorite = () => {
|
||||
toggleMiniApp(app.appId)
|
||||
}
|
||||
|
||||
const handleHide = () => {
|
||||
updateAppStatus(app.appId, 'disabled')
|
||||
.then(() => {
|
||||
@@ -129,6 +136,12 @@ const MiniApp: FC<Props> = ({ app, onClick, onOpen, onEditCustom, size = 60, isL
|
||||
|
||||
const contextMenuItems: CommandContextMenuExtraItem[] = [
|
||||
{ type: 'item', id: 'mini-app.toggle-pin', label: togglePinLabel, onSelect: handleTogglePin },
|
||||
{
|
||||
type: 'item',
|
||||
id: 'mini-app.toggle-sidebar-favorite',
|
||||
label: t(isSidebarFavorite ? 'miniApp.remove_from_sidebar' : 'miniApp.add_to_sidebar'),
|
||||
onSelect: handleToggleSidebarFavorite
|
||||
},
|
||||
...(!isPinned
|
||||
? ([
|
||||
{ type: 'item', id: 'mini-app.hide', label: t('miniApp.sidebar.hide.title'), onSelect: handleHide }
|
||||
|
||||
177
src/renderer/components/MiniApp/__tests__/MiniApp.test.tsx
Normal file
177
src/renderer/components/MiniApp/__tests__/MiniApp.test.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
// @vitest-environment jsdom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import type { SidebarFavoriteItem } from '@shared/data/preference/preferenceTypes'
|
||||
import type { MiniApp as MiniAppType } from '@shared/data/types/miniApp'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const calculatorApp: MiniAppType = {
|
||||
appId: 'calculator',
|
||||
presetMiniAppId: 'calculator',
|
||||
status: 'pinned',
|
||||
orderKey: 'a0',
|
||||
name: 'Calculator',
|
||||
url: 'https://calc.example',
|
||||
logo: 'calculator-logo'
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
openTab: vi.fn(),
|
||||
updateAppStatus: vi.fn(() => Promise.resolve()),
|
||||
removeCustomMiniApp: vi.fn(() => Promise.resolve()),
|
||||
setOpenedKeepAliveMiniApps: vi.fn(),
|
||||
setSidebarFavorites: vi.fn(() => Promise.resolve()),
|
||||
miniApps: [] as MiniAppType[],
|
||||
pinned: [] as MiniAppType[],
|
||||
openedKeepAliveMiniApps: [] as MiniAppType[],
|
||||
sidebarFavorites: [{ type: 'app', id: 'assistants' }] as SidebarFavoriteItem[]
|
||||
}))
|
||||
|
||||
vi.mock('@cherrystudio/ui', () => ({
|
||||
ConfirmDialog: ({ open }: { open?: boolean }) => (open ? <div role="dialog" /> : null)
|
||||
}))
|
||||
|
||||
vi.mock('@logger', () => ({
|
||||
loggerService: {
|
||||
withContext: () => ({
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn()
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/command', () => ({
|
||||
CommandContextMenu: ({
|
||||
children,
|
||||
extraItems
|
||||
}: {
|
||||
children: ReactNode
|
||||
extraItems: Array<{ id: string; label: string; onSelect: () => void }>
|
||||
}) => (
|
||||
<div>
|
||||
{children}
|
||||
{extraItems.map((item) => (
|
||||
<button key={item.id} type="button" onClick={item.onSelect}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/Icons/MiniAppIcon', () => ({
|
||||
default: ({ app }: { app: MiniAppType }) => <div data-testid={`mini-app-icon-${app.appId}`} />
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/IndicatorLight', () => ({
|
||||
default: () => <div data-testid="indicator-light" />
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/MarqueeText', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <span>{children}</span>
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/hooks/useMiniApps', () => ({
|
||||
useMiniApps: () => ({
|
||||
miniApps: mocks.miniApps,
|
||||
pinned: mocks.pinned,
|
||||
openedKeepAliveMiniApps: mocks.openedKeepAliveMiniApps,
|
||||
currentMiniAppId: '',
|
||||
miniAppShow: false,
|
||||
setOpenedKeepAliveMiniApps: mocks.setOpenedKeepAliveMiniApps,
|
||||
updateAppStatus: mocks.updateAppStatus,
|
||||
removeCustomMiniApp: mocks.removeCustomMiniApp
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@data/hooks/usePreference', () => ({
|
||||
usePreference: (key: string) => {
|
||||
if (key === 'ui.sidebar.favorites') return [mocks.sidebarFavorites, mocks.setSidebarFavorites]
|
||||
return [undefined, vi.fn()]
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/hooks/tab', () => ({
|
||||
useTabs: () => ({
|
||||
openTab: mocks.openTab
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
import MiniApp from '../MiniApp'
|
||||
|
||||
beforeEach(() => {
|
||||
window.toast = {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn()
|
||||
} as unknown as typeof window.toast
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
mocks.miniApps = []
|
||||
mocks.pinned = []
|
||||
mocks.openedKeepAliveMiniApps = []
|
||||
mocks.sidebarFavorites = [{ type: 'app', id: 'assistants' }]
|
||||
})
|
||||
|
||||
describe('MiniApp launchpad pin menu', () => {
|
||||
it('adds an enabled mini app to launchpad by pinning status', () => {
|
||||
const enabledApp = { ...calculatorApp, status: 'enabled' as const }
|
||||
mocks.miniApps = [enabledApp]
|
||||
|
||||
render(<MiniApp app={enabledApp} variant="launchpad" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'miniApp.add_to_launchpad' }))
|
||||
|
||||
expect(mocks.updateAppStatus).toHaveBeenCalledWith('calculator', 'pinned')
|
||||
})
|
||||
|
||||
it('adds a mini app to sidebar favorites', () => {
|
||||
const enabledApp = { ...calculatorApp, status: 'enabled' as const }
|
||||
mocks.miniApps = [enabledApp]
|
||||
|
||||
render(<MiniApp app={enabledApp} variant="launchpad" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'miniApp.add_to_sidebar' }))
|
||||
|
||||
expect(mocks.setSidebarFavorites).toHaveBeenCalledWith([
|
||||
{ type: 'app', id: 'assistants' },
|
||||
{ type: 'mini_app', id: 'calculator' }
|
||||
])
|
||||
})
|
||||
|
||||
it('removes a mini app from sidebar favorites', () => {
|
||||
mocks.sidebarFavorites = [
|
||||
{ type: 'app', id: 'assistants' },
|
||||
{ type: 'mini_app', id: 'calculator' },
|
||||
{ type: 'mini_app', id: 'weather' }
|
||||
]
|
||||
mocks.pinned = [calculatorApp]
|
||||
|
||||
render(<MiniApp app={calculatorApp} variant="launchpad" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'miniApp.remove_from_sidebar' }))
|
||||
|
||||
expect(mocks.setSidebarFavorites).toHaveBeenCalledWith([
|
||||
{ type: 'app', id: 'assistants' },
|
||||
{ type: 'mini_app', id: 'weather' }
|
||||
])
|
||||
})
|
||||
|
||||
it('removes a pinned mini app from launchpad by restoring enabled status', () => {
|
||||
mocks.pinned = [calculatorApp]
|
||||
|
||||
render(<MiniApp app={calculatorApp} variant="launchpad" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'miniApp.remove_from_launchpad' }))
|
||||
|
||||
expect(mocks.updateAppStatus).toHaveBeenCalledWith('calculator', 'enabled')
|
||||
})
|
||||
})
|
||||
@@ -195,28 +195,6 @@ export function useModelSelectorData({
|
||||
[modelsByProvider, searchText]
|
||||
)
|
||||
|
||||
const filteredModelsByProvider = useMemo(() => {
|
||||
const nextFilteredModels = new Map<string, Model[]>()
|
||||
|
||||
sortedProviders.forEach((provider) => {
|
||||
const filteredModels = searchFilter(provider).filter((model) => (!showTagFilter ? true : tagFilter(model)))
|
||||
nextFilteredModels.set(provider.id, filteredModels)
|
||||
})
|
||||
|
||||
return nextFilteredModels
|
||||
}, [searchFilter, showTagFilter, sortedProviders, tagFilter])
|
||||
|
||||
const duplicateNamesByProvider = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
sortedProviders.map((provider) => [
|
||||
provider.id,
|
||||
getDuplicateModelNames(filteredModelsByProvider.get(provider.id) ?? [])
|
||||
])
|
||||
),
|
||||
[filteredModelsByProvider, sortedProviders]
|
||||
)
|
||||
|
||||
const createModelItem = useCallback(
|
||||
(model: Model, provider: Provider, isPinned: boolean, showIdentifier: boolean): ModelSelectorModelItem => {
|
||||
const modelId = model.id
|
||||
@@ -313,10 +291,10 @@ export function useModelSelectorData({
|
||||
const selectableModelItems = items.filter((item): item is ModelSelectorModelItem => item.type === 'model')
|
||||
return { listItems: items, modelItems: selectableModelItems }
|
||||
}, [
|
||||
baseModelFilter,
|
||||
createModelItem,
|
||||
duplicateNamesByProvider,
|
||||
filteredModelsByProvider,
|
||||
pinnedIds,
|
||||
searchFilter,
|
||||
selectableModelsById,
|
||||
searchText.length,
|
||||
showPinnedModels,
|
||||
|
||||
@@ -6,65 +6,56 @@ import React, { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { getSidebarDisplayWidth, getSidebarLayout } from './constants'
|
||||
import { DefaultLogo } from './primitives'
|
||||
import { SidebarDocked } from './SidebarDocked'
|
||||
import { SidebarFooter, type SidebarFooterActions } from './SidebarFooter'
|
||||
import { SidebarMenu } from './SidebarMenu'
|
||||
import { SidebarList } from './SidebarList'
|
||||
import { SidebarTooltip } from './Tooltip'
|
||||
import type { SidebarMenuItem, SidebarTab, SidebarUser } from './types'
|
||||
import type { ResolvedSidebarEntry, SidebarActiveState, SidebarUser } from './types'
|
||||
import { useSidebarResize } from './useSidebarResize'
|
||||
|
||||
export interface SidebarProps {
|
||||
width: number
|
||||
setWidth: (width: number) => void
|
||||
activeItem: string
|
||||
items: SidebarMenuItem[]
|
||||
entries: ResolvedSidebarEntry[]
|
||||
active: SidebarActiveState
|
||||
title?: string
|
||||
logo?: React.ReactNode
|
||||
activeTabId?: string
|
||||
dockedTabs?: SidebarTab[]
|
||||
user?: SidebarUser
|
||||
isFloating?: boolean
|
||||
searchLabel?: string
|
||||
extensionsLabel?: string
|
||||
actions?: SidebarFooterActions
|
||||
onItemClick: (id: string) => void
|
||||
onHoverChange?: (visible: boolean) => void
|
||||
onResizePreview?: (width: number | null) => void
|
||||
onSearchClick?: () => void
|
||||
onExtensionsClick?: () => void
|
||||
onMiniAppTabClick?: (tabId: string) => void
|
||||
onStartSidebarDrag?: (e: React.MouseEvent, tabId: string) => void
|
||||
onCloseDockedTab?: (tabId: string) => void
|
||||
onEntriesReorder?: (event: { oldIndex: number; newIndex: number }) => void
|
||||
onDismiss?: () => void
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
width,
|
||||
setWidth,
|
||||
activeItem,
|
||||
items,
|
||||
entries,
|
||||
active,
|
||||
title = '',
|
||||
logo,
|
||||
activeTabId,
|
||||
dockedTabs = [],
|
||||
user,
|
||||
isFloating = false,
|
||||
searchLabel = '',
|
||||
extensionsLabel = '',
|
||||
actions,
|
||||
onItemClick,
|
||||
onHoverChange,
|
||||
onResizePreview,
|
||||
onSearchClick,
|
||||
onExtensionsClick,
|
||||
onMiniAppTabClick,
|
||||
onStartSidebarDrag,
|
||||
onCloseDockedTab,
|
||||
onEntriesReorder,
|
||||
onDismiss
|
||||
}: SidebarProps) {
|
||||
const isMacTransparentWindow = useMacTransparentWindow()
|
||||
const { sidebarRef, startResizing } = useSidebarResize(width, setWidth, onResizePreview)
|
||||
const hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const contextMenuOpenRef = useRef(false)
|
||||
const floatingPointerInsideRef = useRef(false)
|
||||
const layout = getSidebarLayout(width)
|
||||
const showFooter = Boolean(extensionsLabel || user || onExtensionsClick || actions)
|
||||
const showSearch = Boolean(onSearchClick)
|
||||
@@ -80,18 +71,46 @@ export function Sidebar({
|
||||
</div>
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hoverTimeout.current) clearTimeout(hoverTimeout.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
onDismiss?.()
|
||||
}, [onDismiss])
|
||||
|
||||
const menuProps = { items, activeItem, activeTabId, onItemClick, onMiniAppTabClick }
|
||||
const dockedProps = { dockedTabs, activeTabId, onMiniAppTabClick, onStartSidebarDrag, onCloseDockedTab }
|
||||
const clearHoverDismiss = useCallback(() => {
|
||||
if (!hoverTimeout.current) return
|
||||
|
||||
clearTimeout(hoverTimeout.current)
|
||||
hoverTimeout.current = null
|
||||
}, [])
|
||||
|
||||
const scheduleHoverDismiss = useCallback(() => {
|
||||
clearHoverDismiss()
|
||||
hoverTimeout.current = setTimeout(handleDismiss, 300)
|
||||
}, [clearHoverDismiss, handleDismiss])
|
||||
|
||||
useEffect(() => clearHoverDismiss, [clearHoverDismiss])
|
||||
|
||||
const handleContextMenuOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
contextMenuOpenRef.current = open
|
||||
|
||||
if (open) {
|
||||
clearHoverDismiss()
|
||||
return
|
||||
}
|
||||
|
||||
if (isFloating && !floatingPointerInsideRef.current) {
|
||||
scheduleHoverDismiss()
|
||||
}
|
||||
},
|
||||
[clearHoverDismiss, isFloating, scheduleHoverDismiss]
|
||||
)
|
||||
|
||||
const listProps = {
|
||||
entries,
|
||||
active,
|
||||
onReorder: onEntriesReorder,
|
||||
onContextMenuOpenChange: handleContextMenuOpenChange
|
||||
}
|
||||
const footerProps = { user, actions, extensionsLabel, onExtensionsClick }
|
||||
|
||||
// --- Floating sidebar ---
|
||||
@@ -105,11 +124,14 @@ export function Sidebar({
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onMouseLeave={() => {
|
||||
if (hoverTimeout.current) clearTimeout(hoverTimeout.current)
|
||||
hoverTimeout.current = setTimeout(handleDismiss, 300)
|
||||
floatingPointerInsideRef.current = false
|
||||
if (!contextMenuOpenRef.current) {
|
||||
scheduleHoverDismiss()
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (hoverTimeout.current) clearTimeout(hoverTimeout.current)
|
||||
floatingPointerInsideRef.current = true
|
||||
clearHoverDismiss()
|
||||
}}>
|
||||
<div className="flex h-14 shrink-0 items-center gap-2.5 px-4 [-webkit-app-region:drag]">
|
||||
{renderLogo()}
|
||||
@@ -131,8 +153,7 @@ export function Sidebar({
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-1 [&::-webkit-scrollbar]:hidden">
|
||||
<SidebarMenu layout="full" {...menuProps} />
|
||||
<SidebarDocked layout="full" {...dockedProps} />
|
||||
<SidebarList layout="full" {...listProps} />
|
||||
</div>
|
||||
|
||||
{showFooter && (
|
||||
@@ -215,8 +236,7 @@ export function Sidebar({
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto py-1 [&::-webkit-scrollbar]:hidden">
|
||||
<SidebarMenu layout={layout} {...menuProps} />
|
||||
<SidebarDocked layout={layout} {...dockedProps} />
|
||||
<SidebarList layout={layout} {...listProps} />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { X } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
import { ActiveIndicator, SidebarTabIcon } from './primitives'
|
||||
import { SidebarTooltip } from './Tooltip'
|
||||
import type { SidebarTab, SidebarVisibleLayout } from './types'
|
||||
|
||||
export interface SidebarDockedProps {
|
||||
layout: SidebarVisibleLayout
|
||||
dockedTabs: SidebarTab[]
|
||||
activeTabId?: string
|
||||
onMiniAppTabClick?: (tabId: string) => void
|
||||
onStartSidebarDrag?: (e: React.MouseEvent, tabId: string) => void
|
||||
onCloseDockedTab?: (tabId: string) => void
|
||||
}
|
||||
|
||||
export function SidebarDocked({ layout, dockedTabs, ...props }: SidebarDockedProps) {
|
||||
if (dockedTabs.length === 0) return null
|
||||
|
||||
if (layout === 'icon') return <IconDockedTabs dockedTabs={dockedTabs} {...props} />
|
||||
return <FullDockedTabs dockedTabs={dockedTabs} {...props} />
|
||||
}
|
||||
|
||||
type DockedTabsProps = Omit<SidebarDockedProps, 'layout'>
|
||||
|
||||
function IconDockedTabs({
|
||||
dockedTabs,
|
||||
activeTabId,
|
||||
onMiniAppTabClick,
|
||||
onStartSidebarDrag,
|
||||
onCloseDockedTab
|
||||
}: DockedTabsProps) {
|
||||
return (
|
||||
<div className="mt-1 flex flex-col items-center gap-0.5 border-border/30 border-t px-1.5 pt-1 [-webkit-app-region:no-drag]">
|
||||
{dockedTabs.map((dockedTab) => {
|
||||
const isActive = activeTabId === dockedTab.id
|
||||
|
||||
return (
|
||||
<div key={dockedTab.id} className="group/dock relative">
|
||||
<SidebarTooltip content={dockedTab.title}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMiniAppTabClick?.(dockedTab.id)}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation()
|
||||
onStartSidebarDrag?.(event, dockedTab.id)
|
||||
}}
|
||||
className={`relative flex h-7 w-7 cursor-grab items-center justify-center rounded-full transition-all duration-150 active:cursor-grabbing ${
|
||||
isActive ? 'bg-sidebar-active-bg' : 'hover:bg-accent/50'
|
||||
}`}>
|
||||
{isActive && <ActiveIndicator className="rounded-full" />}
|
||||
<SidebarTabIcon tab={dockedTab} size={14} strokeWidth={1.6} miniAppSize="md" />
|
||||
</button>
|
||||
</SidebarTooltip>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onCloseDockedTab?.(dockedTab.id)
|
||||
}}
|
||||
className="-right-1 -top-1 absolute z-10 flex h-3.5 w-3.5 items-center justify-center rounded-full border border-border bg-popover text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/dock:opacity-100">
|
||||
<X size={7} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FullDockedTabs({
|
||||
dockedTabs,
|
||||
activeTabId,
|
||||
onMiniAppTabClick,
|
||||
onStartSidebarDrag,
|
||||
onCloseDockedTab
|
||||
}: DockedTabsProps) {
|
||||
return (
|
||||
<div className="mt-1 space-y-0.5 border-border/30 border-t px-2 pt-1 [-webkit-app-region:no-drag]">
|
||||
{dockedTabs.map((dockedTab) => {
|
||||
const isActive = activeTabId === dockedTab.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dockedTab.id}
|
||||
className={`group/dock relative flex cursor-grab items-center gap-2.5 rounded-xl px-2.5 py-[6px] text-[12px] transition-all duration-150 active:cursor-grabbing ${
|
||||
isActive
|
||||
? 'bg-sidebar-active-bg text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/40 hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => onMiniAppTabClick?.(dockedTab.id)}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation()
|
||||
onStartSidebarDrag?.(event, dockedTab.id)
|
||||
}}>
|
||||
{isActive && <ActiveIndicator className="rounded-xl" glow />}
|
||||
<SidebarTabIcon tab={dockedTab} size={14} strokeWidth={1.6} className="flex-shrink-0" />
|
||||
<span className="flex-1 truncate">{dockedTab.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onCloseDockedTab?.(dockedTab.id)
|
||||
}}
|
||||
className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity hover:bg-foreground/10 group-hover/dock:opacity-100">
|
||||
<X size={9} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
src/renderer/components/Sidebar/SidebarList.tsx
Normal file
115
src/renderer/components/Sidebar/SidebarList.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { MenuItem } from '@cherrystudio/ui'
|
||||
import { CommandContextMenu } from '@renderer/components/command'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { ActiveIndicator } from './primitives'
|
||||
import type { SidebarClickGuard } from './SidebarSortableList'
|
||||
import { SidebarSortableList } from './SidebarSortableList'
|
||||
import { SidebarTooltip } from './Tooltip'
|
||||
import type { ResolvedSidebarEntry, SidebarActiveState, SidebarVisibleLayout } from './types'
|
||||
|
||||
export interface SidebarListProps {
|
||||
layout: SidebarVisibleLayout
|
||||
entries: ResolvedSidebarEntry[]
|
||||
active: SidebarActiveState
|
||||
onReorder?: (event: { oldIndex: number; newIndex: number }) => void
|
||||
onContextMenuOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders built-in apps and mini apps as one continuous, drag-reorderable list.
|
||||
* A single `SidebarSortableList` (one dnd-kit context) backs the whole list, so a
|
||||
* drag can move an item to any position regardless of type — apps and mini apps
|
||||
* freely interleave with no divider between them.
|
||||
*
|
||||
* Entries are already resolved to a type-agnostic shape (see
|
||||
* `components/app/sidebarVariants`), so this presentation layer never switches on
|
||||
* whether a row is an app or a mini app.
|
||||
*/
|
||||
export function SidebarList({ layout, ...props }: SidebarListProps) {
|
||||
if (layout === 'icon') return <IconList {...props} />
|
||||
return <FullList {...props} />
|
||||
}
|
||||
|
||||
type ListProps = Omit<SidebarListProps, 'layout'>
|
||||
|
||||
function EntryContextMenu({
|
||||
children,
|
||||
items,
|
||||
onOpenChange
|
||||
}: {
|
||||
children: ReactNode
|
||||
items?: ResolvedSidebarEntry['contextMenuItems']
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
if (!items?.length) return <>{children}</>
|
||||
|
||||
return (
|
||||
<CommandContextMenu location="webcontents.context" extraItems={items} onOpenChange={onOpenChange}>
|
||||
{children}
|
||||
</CommandContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function IconList({ entries, active, onReorder, onContextMenuOpenChange }: ListProps) {
|
||||
return (
|
||||
<SidebarSortableList
|
||||
items={entries}
|
||||
itemKey="key"
|
||||
onReorder={onReorder}
|
||||
className="flex flex-col items-center gap-0.5 px-1.5 [-webkit-app-region:no-drag]">
|
||||
{(entry, guardClick) => {
|
||||
const isActive = entry.isActive(active)
|
||||
|
||||
return (
|
||||
<SidebarTooltip key={entry.key} content={entry.label}>
|
||||
<EntryContextMenu items={entry.contextMenuItems} onOpenChange={onContextMenuOpenChange}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={entry.label}
|
||||
onClick={guardClick(entry.key, entry.onOpen)}
|
||||
className={`relative flex h-9 w-9 items-center justify-center rounded-full transition-all duration-150 ${
|
||||
isActive
|
||||
? 'bg-sidebar-active-bg text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
|
||||
}`}>
|
||||
{isActive && <ActiveIndicator className="rounded-full" />}
|
||||
{entry.renderIcon(18, 'lg')}
|
||||
</button>
|
||||
</EntryContextMenu>
|
||||
</SidebarTooltip>
|
||||
)
|
||||
}}
|
||||
</SidebarSortableList>
|
||||
)
|
||||
}
|
||||
|
||||
function FullList({ entries, active, onReorder, onContextMenuOpenChange }: ListProps) {
|
||||
return (
|
||||
<SidebarSortableList
|
||||
items={entries}
|
||||
itemKey="key"
|
||||
onReorder={onReorder}
|
||||
className="space-y-0.5 px-2 [-webkit-app-region:no-drag]">
|
||||
{(entry, guardClick: SidebarClickGuard) => {
|
||||
const isActive = entry.isActive(active)
|
||||
|
||||
return (
|
||||
<div key={entry.key} className="relative">
|
||||
<EntryContextMenu items={entry.contextMenuItems} onOpenChange={onContextMenuOpenChange}>
|
||||
<MenuItem
|
||||
variant="ghost"
|
||||
icon={entry.renderIcon(16, 'md')}
|
||||
label={entry.label}
|
||||
active={isActive}
|
||||
onClick={guardClick(entry.key, entry.onOpen)}
|
||||
className="rounded-xl data-[active=true]:bg-sidebar-active-bg"
|
||||
/>
|
||||
</EntryContextMenu>
|
||||
{isActive && <ActiveIndicator className="rounded-xl" />}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</SidebarSortableList>
|
||||
)
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { MenuItem } from '@cherrystudio/ui'
|
||||
|
||||
import { ActiveIndicator, MiniAppIcon } from './primitives'
|
||||
import { SidebarTooltip } from './Tooltip'
|
||||
import type { SidebarMenuItem, SidebarVisibleLayout } from './types'
|
||||
|
||||
export interface SidebarMenuProps {
|
||||
layout: SidebarVisibleLayout
|
||||
items: SidebarMenuItem[]
|
||||
activeItem: string
|
||||
activeTabId?: string
|
||||
onItemClick: (id: string) => void | Promise<void>
|
||||
onMiniAppTabClick?: (tabId: string) => void
|
||||
}
|
||||
|
||||
export function SidebarMenu({ layout, ...props }: SidebarMenuProps) {
|
||||
if (layout === 'icon') return <IconMenuItems {...props} />
|
||||
return <FullMenuItems {...props} />
|
||||
}
|
||||
|
||||
type MenuItemsProps = Omit<SidebarMenuProps, 'layout'>
|
||||
|
||||
function IconMenuItems({ items, activeItem, activeTabId, onItemClick, onMiniAppTabClick }: MenuItemsProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-0.5 px-1.5 [-webkit-app-region:no-drag]">
|
||||
{items.map((item) => {
|
||||
const isActive = activeItem === item.id
|
||||
const Icon = item.icon
|
||||
const miniTabs = item.miniAppTabs ?? []
|
||||
|
||||
return (
|
||||
<div key={item.id} className="contents">
|
||||
<SidebarTooltip content={item.label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onItemClick(item.id)}
|
||||
className={`relative flex h-9 w-9 items-center justify-center rounded-full transition-all duration-150 ${
|
||||
isActive
|
||||
? 'bg-sidebar-active-bg text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
|
||||
}`}>
|
||||
{isActive && <ActiveIndicator className="rounded-full" />}
|
||||
<Icon size={18} strokeWidth={1.6} />
|
||||
</button>
|
||||
</SidebarTooltip>
|
||||
|
||||
{miniTabs.map((miniTab) => (
|
||||
<SidebarTooltip key={miniTab.id} content={miniTab.title}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMiniAppTabClick?.(miniTab.id)}
|
||||
className={`relative flex h-7 w-7 items-center justify-center rounded-full transition-all duration-150 ${
|
||||
activeTabId === miniTab.id ? 'bg-sidebar-active-bg' : 'hover:bg-accent/50'
|
||||
}`}>
|
||||
{activeTabId === miniTab.id && <ActiveIndicator className="rounded-full" />}
|
||||
<MiniAppIcon tab={miniTab} size="md" />
|
||||
</button>
|
||||
</SidebarTooltip>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FullMenuItems({ items, activeItem, activeTabId, onItemClick, onMiniAppTabClick }: MenuItemsProps) {
|
||||
return (
|
||||
<div className="space-y-0.5 px-2 [-webkit-app-region:no-drag]">
|
||||
{items.map((item) => {
|
||||
const isActive = activeItem === item.id
|
||||
const Icon = item.icon
|
||||
const miniTabs = item.miniAppTabs ?? []
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<div className="relative">
|
||||
<MenuItem
|
||||
variant="ghost"
|
||||
icon={<Icon size={16} strokeWidth={1.6} />}
|
||||
label={item.label}
|
||||
active={isActive}
|
||||
onClick={() => void onItemClick(item.id)}
|
||||
className="rounded-xl data-[active=true]:bg-sidebar-active-bg"
|
||||
/>
|
||||
{isActive && <ActiveIndicator className="rounded-xl" />}
|
||||
</div>
|
||||
|
||||
{miniTabs.map((miniTab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={miniTab.id}
|
||||
onClick={() => onMiniAppTabClick?.(miniTab.id)}
|
||||
className={`relative flex w-full items-center gap-2 rounded-xl py-[5px] pr-2.5 pl-7 text-[12px] transition-all duration-150 ${
|
||||
activeTabId === miniTab.id
|
||||
? 'bg-sidebar-active-bg text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/40 hover:text-foreground'
|
||||
}`}>
|
||||
{activeTabId === miniTab.id && <ActiveIndicator className="rounded-xl" glow />}
|
||||
<MiniAppIcon tab={miniTab} />
|
||||
<span className="truncate">{miniTab.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
src/renderer/components/Sidebar/SidebarSortableList.tsx
Normal file
73
src/renderer/components/Sidebar/SidebarSortableList.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Sortable } from '@cherrystudio/ui'
|
||||
import type { Active } from '@dnd-kit/core'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* After a drag-drop, dnd-kit fires a trailing synthetic click on the dragged
|
||||
* element; swallow clicks for a short window so a reorder never navigates.
|
||||
*/
|
||||
const DRAG_CLICK_SUPPRESS_MS = 250
|
||||
|
||||
/** Wrap a click handler so it is ignored right after that item was dragged. */
|
||||
export type SidebarClickGuard = (item: unknown, handler: () => void) => () => void
|
||||
|
||||
interface SidebarSortableListProps<T> {
|
||||
items: T[]
|
||||
itemKey: keyof T
|
||||
/** Container classes; applied to both the sortable and the plain fallback list. */
|
||||
className?: string
|
||||
/** When provided the list is drag-sortable; otherwise it renders a static list. */
|
||||
onReorder?: (event: { oldIndex: number; newIndex: number }) => void
|
||||
children: (item: T, guardClick: SidebarClickGuard) => ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders resolved sidebar entries as one generic sortable list. The caller
|
||||
* decides whether the entries are built-in apps, mini apps, or future item types.
|
||||
*/
|
||||
export function SidebarSortableList<T>({
|
||||
items,
|
||||
itemKey,
|
||||
className,
|
||||
onReorder,
|
||||
children
|
||||
}: SidebarSortableListProps<T>) {
|
||||
const suppressClickUntilRef = useRef(0)
|
||||
const draggedItemIdRef = useRef<string | null>(null)
|
||||
|
||||
const markDragStarted = useCallback((event: { active: Active }) => {
|
||||
draggedItemIdRef.current = String(event.active.id)
|
||||
suppressClickUntilRef.current = Date.now() + DRAG_CLICK_SUPPRESS_MS
|
||||
}, [])
|
||||
|
||||
const markDragSettled = useCallback(() => {
|
||||
suppressClickUntilRef.current = Date.now() + DRAG_CLICK_SUPPRESS_MS
|
||||
}, [])
|
||||
|
||||
const guardClick = useCallback<SidebarClickGuard>(
|
||||
(item, handler) => () => {
|
||||
if (String(item) === draggedItemIdRef.current && Date.now() < suppressClickUntilRef.current) return
|
||||
handler()
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
if (!onReorder) {
|
||||
return <div className={className}>{items.map((item) => children(item, guardClick))}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<Sortable
|
||||
items={items}
|
||||
itemKey={itemKey}
|
||||
layout="list"
|
||||
className={className}
|
||||
onDragStart={markDragStarted}
|
||||
onDragEnd={markDragSettled}
|
||||
onDragCancel={markDragSettled}
|
||||
onSortEnd={onReorder}
|
||||
renderItem={(item) => children(item, guardClick)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Search } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getSidebarDisplayWidth,
|
||||
@@ -11,8 +12,53 @@ import {
|
||||
SIDEBAR_ICON_WIDTH,
|
||||
SIDEBAR_MAX_WIDTH
|
||||
} from '../constants'
|
||||
import { MiniAppIcon } from '../primitives'
|
||||
import { Sidebar } from '../Sidebar'
|
||||
import type { SidebarMenuItem } from '../types'
|
||||
import type { ResolvedSidebarEntry, SidebarMiniAppTab } from '../types'
|
||||
|
||||
type AppItem = {
|
||||
id: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
contextMenuItems?: ResolvedSidebarEntry['contextMenuItems']
|
||||
}
|
||||
|
||||
const uiMocks = vi.hoisted(() => ({
|
||||
sortableCalls: [] as any[]
|
||||
}))
|
||||
|
||||
vi.mock('@cherrystudio/ui', () => ({
|
||||
MenuItem: ({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
className,
|
||||
active
|
||||
}: {
|
||||
icon?: ReactNode
|
||||
label: string
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
active?: boolean
|
||||
}) => (
|
||||
<button type="button" data-active={active ? 'true' : 'false'} className={className} onClick={onClick}>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
),
|
||||
Sortable: ({ items, itemKey, renderItem, ...props }: any) => {
|
||||
uiMocks.sortableCalls.push({ items, itemKey, renderItem, ...props })
|
||||
const getKey = typeof itemKey === 'function' ? itemKey : (item: any) => item[itemKey]
|
||||
|
||||
return (
|
||||
<div>
|
||||
{items.map((item: any) => (
|
||||
<div key={getKey(item)}>{renderItem(item)}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../Tooltip', () => ({
|
||||
SidebarTooltip: ({ children }: { children: ReactNode }) => children
|
||||
@@ -22,16 +68,92 @@ vi.mock('@renderer/hooks/useMacTransparentWindow', () => ({
|
||||
default: () => false
|
||||
}))
|
||||
|
||||
const items: SidebarMenuItem[] = [
|
||||
vi.mock('@renderer/components/command', () => ({
|
||||
CommandContextMenu: ({
|
||||
children,
|
||||
extraItems,
|
||||
onOpenChange
|
||||
}: {
|
||||
children: ReactNode
|
||||
extraItems: ReadonlyArray<{ id: string; label: string; enabled?: boolean; onSelect?: () => void }>
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => (
|
||||
<div data-testid="command-context-menu">
|
||||
{children}
|
||||
{onOpenChange && (
|
||||
<>
|
||||
<button type="button" data-testid="context-menu-open" onClick={() => onOpenChange(true)} />
|
||||
<button type="button" data-testid="context-menu-close" onClick={() => onOpenChange(false)} />
|
||||
</>
|
||||
)}
|
||||
{extraItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-testid={`context-menu-${item.id}`}
|
||||
disabled={item.enabled === false}
|
||||
onClick={item.onSelect}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/Icons/miniAppsLogo', () => ({
|
||||
getMiniAppsLogo: (logo?: string) => {
|
||||
if (logo !== 'qwen') return undefined
|
||||
|
||||
const QwenLogo = ({ style, ...props }: { style?: CSSProperties }) => (
|
||||
<svg data-testid="resolved-mini-app-logo" style={style} {...props} />
|
||||
)
|
||||
QwenLogo.Avatar = ({ size }: { size: number }) => (
|
||||
<span data-size={size} data-testid="resolved-mini-app-logo-avatar" />
|
||||
)
|
||||
return QwenLogo
|
||||
}
|
||||
}))
|
||||
|
||||
// Build the type-agnostic resolved entries the real registry would produce, so the
|
||||
// presentation tests exercise the same shape without depending on app wiring.
|
||||
const appEntry = (item: AppItem): ResolvedSidebarEntry => ({
|
||||
key: `app:${item.id}`,
|
||||
label: item.label,
|
||||
renderIcon: (size) => {
|
||||
const Icon = item.icon
|
||||
return <Icon size={size} strokeWidth={1.6} />
|
||||
},
|
||||
isActive: (active) => active.activeItem === item.id,
|
||||
onOpen: () => {},
|
||||
contextMenuItems: item.contextMenuItems
|
||||
})
|
||||
const miniEntry = (
|
||||
tab: SidebarMiniAppTab,
|
||||
contextMenuItems?: ResolvedSidebarEntry['contextMenuItems']
|
||||
): ResolvedSidebarEntry => ({
|
||||
key: `mini_app:${tab.miniApp.id}`,
|
||||
label: tab.title,
|
||||
renderIcon: (_size, miniAppSize) => <MiniAppIcon tab={tab} size={miniAppSize} />,
|
||||
isActive: (active) => active.activeTabId === tab.miniApp.id,
|
||||
onOpen: () => {},
|
||||
contextMenuItems
|
||||
})
|
||||
|
||||
const items: AppItem[] = [
|
||||
{
|
||||
id: 'chat',
|
||||
label: 'Chat',
|
||||
icon: Search
|
||||
}
|
||||
]
|
||||
const entries: ResolvedSidebarEntry[] = items.map(appEntry)
|
||||
|
||||
const INTERMEDIATE_WIDTH = SIDEBAR_ICON_WIDTH + 30
|
||||
|
||||
afterEach(() => {
|
||||
uiMocks.sortableCalls.length = 0
|
||||
})
|
||||
|
||||
function dragResizeFrom(width: number, moves: number | number[]) {
|
||||
const setWidth = vi.fn()
|
||||
const onResizePreview = vi.fn()
|
||||
@@ -40,9 +162,8 @@ function dragResizeFrom(width: number, moves: number | number[]) {
|
||||
<Sidebar
|
||||
width={width}
|
||||
setWidth={setWidth}
|
||||
activeItem="chat"
|
||||
items={items}
|
||||
onItemClick={vi.fn()}
|
||||
active={{ activeItem: 'chat' }}
|
||||
entries={entries}
|
||||
onHoverChange={onHoverChange}
|
||||
onResizePreview={onResizePreview}
|
||||
/>
|
||||
@@ -61,7 +182,7 @@ function dragResizeFrom(width: number, moves: number | number[]) {
|
||||
describe('Sidebar resize handle', () => {
|
||||
it('keeps the existing handle width and opts out of window drag regions', () => {
|
||||
const { container } = render(
|
||||
<Sidebar width={SIDEBAR_ICON_WIDTH} setWidth={vi.fn()} activeItem="chat" items={items} onItemClick={vi.fn()} />
|
||||
<Sidebar width={SIDEBAR_ICON_WIDTH} setWidth={vi.fn()} active={{ activeItem: 'chat' }} entries={entries} />
|
||||
)
|
||||
|
||||
const resizeHandle = container.querySelector('.cursor-col-resize')
|
||||
@@ -135,7 +256,7 @@ describe('Sidebar resize handle', () => {
|
||||
|
||||
it('renders intermediate widths with icon layout without menu text', () => {
|
||||
const { container, queryByText } = render(
|
||||
<Sidebar width={INTERMEDIATE_WIDTH} setWidth={vi.fn()} activeItem="chat" items={items} onItemClick={vi.fn()} />
|
||||
<Sidebar width={INTERMEDIATE_WIDTH} setWidth={vi.fn()} active={{ activeItem: 'chat' }} entries={entries} />
|
||||
)
|
||||
|
||||
expect(container.firstElementChild).toHaveStyle({ width: `${INTERMEDIATE_WIDTH}px` })
|
||||
@@ -159,9 +280,8 @@ describe('Sidebar resize handle', () => {
|
||||
<Sidebar
|
||||
width={SIDEBAR_HIDDEN_THRESHOLD - 10}
|
||||
setWidth={vi.fn()}
|
||||
activeItem="chat"
|
||||
items={items}
|
||||
onItemClick={vi.fn()}
|
||||
active={{ activeItem: 'chat' }}
|
||||
entries={entries}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -187,19 +307,228 @@ describe('Sidebar resize handle', () => {
|
||||
|
||||
it('renders the full layout at the full threshold', () => {
|
||||
const { container, getByText } = render(
|
||||
<Sidebar
|
||||
width={SIDEBAR_FULL_THRESHOLD}
|
||||
setWidth={vi.fn()}
|
||||
activeItem="chat"
|
||||
items={items}
|
||||
onItemClick={vi.fn()}
|
||||
/>
|
||||
<Sidebar width={SIDEBAR_FULL_THRESHOLD} setWidth={vi.fn()} active={{ activeItem: 'chat' }} entries={entries} />
|
||||
)
|
||||
|
||||
expect(container.firstElementChild).toHaveStyle({ width: `${SIDEBAR_FULL_THRESHOLD}px` })
|
||||
expect(getByText('Chat')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('wires context menu actions for sidebar app items', () => {
|
||||
const onRemove = vi.fn()
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
width={SIDEBAR_FULL_THRESHOLD}
|
||||
setWidth={vi.fn()}
|
||||
active={{ activeItem: 'chat' }}
|
||||
entries={[
|
||||
appEntry({
|
||||
...items[0],
|
||||
contextMenuItems: [{ type: 'item', id: 'remove-chat', label: 'Remove from Sidebar', onSelect: onRemove }]
|
||||
})
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('context-menu-remove-chat'))
|
||||
|
||||
expect(onRemove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the floating sidebar open while a context menu is open', () => {
|
||||
vi.useFakeTimers()
|
||||
const onDismiss = vi.fn()
|
||||
|
||||
try {
|
||||
const { container } = render(
|
||||
<Sidebar
|
||||
width={SIDEBAR_FULL_THRESHOLD}
|
||||
setWidth={vi.fn()}
|
||||
active={{ activeItem: 'chat' }}
|
||||
entries={[
|
||||
appEntry({
|
||||
...items[0],
|
||||
contextMenuItems: [{ type: 'item', id: 'remove-chat', label: 'Remove from Sidebar', onSelect: vi.fn() }]
|
||||
})
|
||||
]}
|
||||
isFloating
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
)
|
||||
|
||||
const panel = container.querySelector('.slide-in-from-left-2') as HTMLElement
|
||||
|
||||
fireEvent.mouseEnter(panel)
|
||||
fireEvent.click(screen.getByTestId('context-menu-open'))
|
||||
fireEvent.mouseLeave(panel)
|
||||
vi.advanceTimersByTime(350)
|
||||
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByTestId('context-menu-close'))
|
||||
vi.advanceTimersByTime(350)
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders full docked mini app icons directly without avatar chrome', () => {
|
||||
const { container } = render(
|
||||
<Sidebar
|
||||
width={SIDEBAR_FULL_THRESHOLD}
|
||||
setWidth={vi.fn()}
|
||||
active={{ activeItem: 'chat' }}
|
||||
entries={[
|
||||
...entries,
|
||||
miniEntry({
|
||||
title: 'Qwen',
|
||||
miniApp: { id: 'qwen', logo: 'qwen' }
|
||||
})
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(container.querySelector('[data-testid="resolved-mini-app-logo-avatar"]')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('[data-testid="resolved-mini-app-logo"]')).toHaveStyle({
|
||||
width: '16px',
|
||||
height: '16px'
|
||||
})
|
||||
})
|
||||
|
||||
it('renders apps and mini apps together in one continuous list', () => {
|
||||
const dockedTab: SidebarMiniAppTab = {
|
||||
title: 'Qwen',
|
||||
miniApp: { id: 'qwen', logo: 'qwen' }
|
||||
}
|
||||
|
||||
const { getByText } = render(
|
||||
<Sidebar
|
||||
width={SIDEBAR_FULL_THRESHOLD}
|
||||
setWidth={vi.fn()}
|
||||
active={{ activeItem: 'chat' }}
|
||||
entries={[...entries, miniEntry(dockedTab)]}
|
||||
/>
|
||||
)
|
||||
|
||||
// App and mini app rows go through the same resolved-entry render path, so both
|
||||
// appear in the single list.
|
||||
expect(getByText('Chat')).toBeInTheDocument()
|
||||
expect(getByText('Qwen')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('gives docked mini apps the shared icon-row button sizing and hover styles', () => {
|
||||
const { container } = render(
|
||||
<Sidebar
|
||||
width={SIDEBAR_ICON_WIDTH}
|
||||
setWidth={vi.fn()}
|
||||
active={{ activeItem: 'chat' }}
|
||||
entries={[
|
||||
...entries,
|
||||
miniEntry({
|
||||
title: 'Qwen',
|
||||
miniApp: { id: 'qwen', logo: 'qwen' }
|
||||
})
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
const miniAppLogo = container.querySelector('[data-testid="resolved-mini-app-logo"]')
|
||||
const dockedMiniAppButton = miniAppLogo?.closest('button')
|
||||
|
||||
expect(miniAppLogo).toHaveStyle({ width: '22px', height: '22px' })
|
||||
expect(dockedMiniAppButton).toHaveClass('h-9', 'w-9')
|
||||
expect(dockedMiniAppButton).toHaveClass('hover:bg-accent/60', 'hover:text-foreground')
|
||||
})
|
||||
|
||||
it('names icon-only docked mini app buttons from the full title when the logo is missing', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
width={SIDEBAR_ICON_WIDTH}
|
||||
setWidth={vi.fn()}
|
||||
active={{ activeItem: 'chat' }}
|
||||
entries={[
|
||||
...entries,
|
||||
miniEntry({
|
||||
title: 'Custom Tool',
|
||||
miniApp: { id: 'custom' }
|
||||
})
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Custom Tool' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('wires context menu actions for docked mini app icons', () => {
|
||||
const onRemove = vi.fn()
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
width={SIDEBAR_ICON_WIDTH}
|
||||
setWidth={vi.fn()}
|
||||
active={{ activeItem: 'chat' }}
|
||||
entries={[
|
||||
...entries,
|
||||
miniEntry(
|
||||
{
|
||||
title: 'Qwen',
|
||||
miniApp: { id: 'qwen', logo: 'qwen' }
|
||||
},
|
||||
[{ type: 'item', id: 'remove-qwen', label: 'Remove from Sidebar', onSelect: onRemove }]
|
||||
)
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('context-menu-remove-qwen'))
|
||||
|
||||
expect(onRemove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('suppresses only the dragged sidebar entry click after sorting settles', () => {
|
||||
const onChatOpen = vi.fn()
|
||||
const onAgentOpen = vi.fn()
|
||||
const sortableEntries: ResolvedSidebarEntry[] = [
|
||||
{
|
||||
key: 'app:chat',
|
||||
label: 'Chat',
|
||||
renderIcon: () => null,
|
||||
isActive: (active) => active.activeItem === 'chat',
|
||||
onOpen: onChatOpen
|
||||
},
|
||||
{
|
||||
key: 'app:agent',
|
||||
label: 'Agent',
|
||||
renderIcon: () => null,
|
||||
isActive: (active) => active.activeItem === 'agent',
|
||||
onOpen: onAgentOpen
|
||||
}
|
||||
]
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
width={SIDEBAR_FULL_THRESHOLD}
|
||||
setWidth={vi.fn()}
|
||||
active={{ activeItem: 'chat' }}
|
||||
entries={sortableEntries}
|
||||
onEntriesReorder={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
const sortableCall = uiMocks.sortableCalls.at(-1)
|
||||
sortableCall.onDragStart({ active: { id: 'app:chat' } })
|
||||
sortableCall.onDragEnd()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Chat' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Agent' }))
|
||||
|
||||
expect(onChatOpen).not.toHaveBeenCalled()
|
||||
expect(onAgentOpen).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders footer actions with the current sidebar layout', () => {
|
||||
const renderActions = (layout: 'icon' | 'full') => <button type="button">theme-{layout}</button>
|
||||
|
||||
@@ -207,10 +536,9 @@ describe('Sidebar resize handle', () => {
|
||||
<Sidebar
|
||||
width={SIDEBAR_ICON_WIDTH}
|
||||
setWidth={vi.fn()}
|
||||
activeItem="chat"
|
||||
items={items}
|
||||
active={{ activeItem: 'chat' }}
|
||||
entries={entries}
|
||||
actions={renderActions}
|
||||
onItemClick={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -220,10 +548,9 @@ describe('Sidebar resize handle', () => {
|
||||
<Sidebar
|
||||
width={SIDEBAR_FULL_THRESHOLD}
|
||||
setWidth={vi.fn()}
|
||||
activeItem="chat"
|
||||
items={items}
|
||||
active={{ activeItem: 'chat' }}
|
||||
entries={entries}
|
||||
actions={renderActions}
|
||||
onItemClick={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -236,10 +563,9 @@ describe('Sidebar resize handle', () => {
|
||||
<Sidebar
|
||||
width={SIDEBAR_HIDDEN_THRESHOLD - 10}
|
||||
setWidth={vi.fn()}
|
||||
activeItem="chat"
|
||||
items={items}
|
||||
active={{ activeItem: 'chat' }}
|
||||
entries={entries}
|
||||
isFloating
|
||||
onItemClick={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { EmojiIcon } from '@cherrystudio/ui'
|
||||
import { LogoAvatar } from '@renderer/components/Icons'
|
||||
import MiniAppLogo from '@renderer/components/Icons/MiniAppIcon'
|
||||
import { isEmoji } from '@renderer/utils/naming'
|
||||
import type { LucideProps } from 'lucide-react'
|
||||
|
||||
import type { SidebarMiniAppTab, SidebarTab, SidebarUser } from './types'
|
||||
import type { SidebarMiniAppTab, SidebarUser } from './types'
|
||||
|
||||
type MiniAppIconSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
export function ActiveIndicator({ className, glow = false }: { className?: string; glow?: boolean }) {
|
||||
return (
|
||||
@@ -27,16 +28,17 @@ export function DefaultLogo({ title }: { title: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function MiniAppIcon({ tab, size = 'sm' }: { tab: SidebarMiniAppTab; size?: 'sm' | 'md' }) {
|
||||
const pixelSize = size === 'sm' ? 14 : 16
|
||||
const iconSize = size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'
|
||||
const fontSize = size === 'sm' ? 'text-[6px]' : 'text-[8px]'
|
||||
export function MiniAppIcon({ tab, size = 'sm' }: { tab: SidebarMiniAppTab; size?: MiniAppIconSize }) {
|
||||
const pixelSize = size === 'sm' ? 14 : size === 'md' ? 16 : 22
|
||||
const { miniApp } = tab
|
||||
|
||||
if (miniApp.logo) {
|
||||
return <LogoAvatar logo={miniApp.logo} size={pixelSize} shape="rounded" />
|
||||
return <MiniAppLogo app={{ logo: miniApp.logo, name: tab.title }} appearance="bare" size={pixelSize} />
|
||||
}
|
||||
|
||||
const iconSize = size === 'sm' ? 'h-3.5 w-3.5' : size === 'md' ? 'h-4 w-4' : 'h-[22px] w-[22px]'
|
||||
const fontSize = size === 'sm' ? 'text-[6px]' : size === 'md' ? 'text-[8px]' : 'text-[11px]'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${iconSize} ${fontSize} flex flex-shrink-0 items-center justify-center rounded-[3px] text-white`}
|
||||
@@ -46,18 +48,6 @@ export function MiniAppIcon({ tab, size = 'sm' }: { tab: SidebarMiniAppTab; size
|
||||
)
|
||||
}
|
||||
|
||||
export function SidebarTabIcon({
|
||||
tab,
|
||||
miniAppSize = 'sm',
|
||||
...iconProps
|
||||
}: { tab: SidebarTab; miniAppSize?: 'sm' | 'md' } & LucideProps) {
|
||||
if (tab.type === 'miniapp') {
|
||||
return <MiniAppIcon tab={tab} size={miniAppSize} />
|
||||
}
|
||||
const Icon = tab.icon
|
||||
return <Icon {...iconProps} />
|
||||
}
|
||||
|
||||
/** Returns true if the string is NOT a URL — i.e., should be rendered as text (emoji or initial). */
|
||||
function isTextAvatar(str?: string): boolean {
|
||||
if (
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
import type { CompoundIcon } from '@cherrystudio/ui'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { CommandContextMenuExtraItem } from '@renderer/components/command'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export interface SidebarMiniApp {
|
||||
id: string
|
||||
color?: string
|
||||
url?: string
|
||||
logo?: string | CompoundIcon
|
||||
logo?: string
|
||||
}
|
||||
|
||||
export interface SidebarMiniAppTab {
|
||||
id: string
|
||||
title: string
|
||||
type: 'miniapp'
|
||||
miniApp: SidebarMiniApp
|
||||
}
|
||||
|
||||
export interface SidebarMenuItem {
|
||||
id: string
|
||||
/** The active-route state a resolved entry matches itself against. */
|
||||
export interface SidebarActiveState {
|
||||
/** Active built-in app id. */
|
||||
activeItem: string
|
||||
/** Active mini app id (concrete mini app route). */
|
||||
activeTabId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A fully-resolved, type-agnostic sidebar row. The app layer produces these from
|
||||
* the tagged favorites via the variant registry (see `components/app/sidebarVariants`);
|
||||
* the presentation layer renders them without knowing whether a row is a built-in
|
||||
* app or a mini app. Adding a new sidebar item type is a new variant descriptor —
|
||||
* leaf item rows keep this presentation contract.
|
||||
*/
|
||||
export interface ResolvedSidebarEntry {
|
||||
/** Stable identity — react key and reorder-matching key (`${type}:${id}`). */
|
||||
key: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
miniAppTabs?: SidebarMiniAppTab[]
|
||||
renderIcon: (size: number, miniAppSize: 'md' | 'lg') => ReactNode
|
||||
isActive: (active: SidebarActiveState) => boolean
|
||||
onOpen: () => void
|
||||
contextMenuItems?: readonly CommandContextMenuExtraItem[]
|
||||
}
|
||||
|
||||
export interface SidebarRouteTab {
|
||||
id: string
|
||||
title: string
|
||||
type: 'route'
|
||||
icon: LucideIcon
|
||||
sourceMenuItemId?: string
|
||||
dockTarget?: 'sidebar'
|
||||
}
|
||||
|
||||
export type SidebarTab = SidebarRouteTab | SidebarMiniAppTab
|
||||
|
||||
export type SidebarLayout = 'hidden' | 'icon' | 'full'
|
||||
|
||||
export type SidebarVisibleLayout = Exclude<SidebarLayout, 'hidden'>
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { usePersistCache } from '@data/hooks/useCache'
|
||||
import { usePreference } from '@data/hooks/usePreference'
|
||||
import { SIDEBAR_ICON_COMPONENTS } from '@renderer/components/app/sidebarIcons'
|
||||
import { arrayMove } from '@dnd-kit/sortable'
|
||||
import {
|
||||
emitResourceListReveal,
|
||||
type ResourceListRevealSource
|
||||
} from '@renderer/components/chat/resources/resourceListRevealEvents'
|
||||
import { useTabs } from '@renderer/hooks/tab'
|
||||
import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { getSidebarIconLabelKey } from '@renderer/i18n/label'
|
||||
import { useMiniApps } from '@renderer/hooks/useMiniApps'
|
||||
import { useSidebarFavorites } from '@renderer/hooks/useSidebarFavorites'
|
||||
import { getDefaultRouteTitle } from '@renderer/utils/routeTitle'
|
||||
import type { SidebarAppId } from '@renderer/utils/sidebar'
|
||||
import {
|
||||
getOrderedVisibleSidebarFavorites,
|
||||
getSidebarFavoriteKey,
|
||||
getSidebarMenuPath,
|
||||
REQUIRED_SIDEBAR_FAVORITES,
|
||||
resolveSidebarActiveItem
|
||||
} from '@renderer/utils/sidebar'
|
||||
import { clearTabInstanceMetadata } from '@renderer/utils/tabInstanceMetadata'
|
||||
import type { SidebarFavorite } from '@shared/data/preference/preferenceTypes'
|
||||
import type { Ref } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -25,20 +27,29 @@ import UserPopup from '../Popups/UserPopup'
|
||||
import { Sidebar as UISidebar } from '../Sidebar'
|
||||
import { getSidebarDisplayWidth, getSidebarLayout, normalizeSidebarWidth } from '../Sidebar/constants'
|
||||
import { UserAvatar } from '../Sidebar/primitives'
|
||||
import type { SidebarMenuItem, SidebarUser, SidebarVisibleLayout } from '../Sidebar/types'
|
||||
import type { SidebarUser, SidebarVisibleLayout } from '../Sidebar/types'
|
||||
import { resolveSidebarEntry, type SidebarVariantContext } from './sidebarVariants'
|
||||
|
||||
const noop = () => {}
|
||||
const MINI_APP_ROUTE_PREFIX = '/app/mini-app/'
|
||||
const REQUIRED_SIDEBAR_FAVORITE_SET = new Set<SidebarAppId>(REQUIRED_SIDEBAR_FAVORITES)
|
||||
|
||||
function getResourceListRevealSource(menuItemId: SidebarFavorite): ResourceListRevealSource | null {
|
||||
function getResourceListRevealSource(menuItemId: SidebarAppId): ResourceListRevealSource | null {
|
||||
if (menuItemId === 'assistants' || menuItemId === 'agents') return menuItemId
|
||||
return null
|
||||
}
|
||||
|
||||
function getMiniAppIdFromUrl(url: string | undefined): string | undefined {
|
||||
if (!url?.startsWith(MINI_APP_ROUTE_PREFIX)) return undefined
|
||||
const appId = url.slice(MINI_APP_ROUTE_PREFIX.length).split(/[/?#]/, 1)[0]
|
||||
return appId || undefined
|
||||
}
|
||||
|
||||
export default function Sidebar({ ref }: { ref?: Ref<HTMLDivElement | null> }) {
|
||||
const { t } = useTranslation()
|
||||
const [userName] = usePreference('app.user.name')
|
||||
const [sidebarFavorites] = usePreference('ui.sidebar.favorites')
|
||||
const { favorites, setAppPinned, removeMiniApp, reorderFavorites } = useSidebarFavorites()
|
||||
const { activeTab, updateTab, openTab } = useTabs()
|
||||
const { miniApps, pinned } = useMiniApps()
|
||||
const [defaultPaintingProvider] = usePreference('feature.paintings.default_provider')
|
||||
|
||||
// Sidebar width — persisted across restarts. Dragging through the
|
||||
@@ -96,31 +107,31 @@ export default function Sidebar({ ref }: { ref?: Ref<HTMLDivElement | null> }) {
|
||||
|
||||
// Menu items
|
||||
const pathname = activeTab?.url || '/'
|
||||
const activeMiniAppId = getMiniAppIdFromUrl(activeTab?.url)
|
||||
const openableMiniAppById = useMemo(() => {
|
||||
const appById = new Map<string, (typeof miniApps)[number]>()
|
||||
for (const app of miniApps) {
|
||||
appById.set(app.appId, app)
|
||||
}
|
||||
for (const app of pinned) {
|
||||
appById.set(app.appId, app)
|
||||
}
|
||||
return appById
|
||||
}, [miniApps, pinned])
|
||||
|
||||
const items = useMemo<SidebarMenuItem[]>(
|
||||
() =>
|
||||
getOrderedVisibleSidebarFavorites(sidebarFavorites).flatMap((icon) => {
|
||||
const path = getSidebarMenuPath(icon, defaultPaintingProvider)
|
||||
const Icon = SIDEBAR_ICON_COMPONENTS[icon]
|
||||
if (!path || !Icon) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: icon,
|
||||
label: t(getSidebarIconLabelKey(icon)),
|
||||
icon: Icon
|
||||
}
|
||||
]
|
||||
}),
|
||||
[defaultPaintingProvider, sidebarFavorites, t]
|
||||
const handleRemoveSidebarFavorite = useCallback(
|
||||
(favorite: SidebarAppId) => {
|
||||
if (REQUIRED_SIDEBAR_FAVORITE_SET.has(favorite)) return
|
||||
setAppPinned(favorite, false)
|
||||
},
|
||||
[setAppPinned]
|
||||
)
|
||||
|
||||
const activeItem = resolveSidebarActiveItem(pathname)
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(menuItemId: string) => {
|
||||
const menuId = menuItemId as SidebarFavorite
|
||||
const menuId = menuItemId as SidebarAppId
|
||||
const path = getSidebarMenuPath(menuId, defaultPaintingProvider)
|
||||
if (!path || activeTab?.url === path) return
|
||||
|
||||
@@ -159,18 +170,97 @@ export default function Sidebar({ ref }: { ref?: Ref<HTMLDivElement | null> }) {
|
||||
openTab('/settings/provider', { title: t('settings.title') })
|
||||
}, [openTab, t])
|
||||
|
||||
const handleOpenMiniAppTab = useCallback(
|
||||
(appId: string) => {
|
||||
const app = openableMiniAppById.get(appId)
|
||||
if (!app) return
|
||||
|
||||
const path = `${MINI_APP_ROUTE_PREFIX}${app.appId}`
|
||||
if (activeTab?.url === path) return
|
||||
|
||||
const title = app.nameKey ? t(app.nameKey) : app.name
|
||||
|
||||
if (activeTab?.isPinned) {
|
||||
openTab(path, { forceNew: true, title, icon: app.logo })
|
||||
return
|
||||
}
|
||||
|
||||
if (activeTab) {
|
||||
updateTab(activeTab.id, {
|
||||
url: path,
|
||||
title,
|
||||
icon: app.logo,
|
||||
metadata: clearTabInstanceMetadata(activeTab.metadata)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
openTab(path, {
|
||||
forceNew: true,
|
||||
title,
|
||||
icon: app.logo
|
||||
})
|
||||
},
|
||||
[activeTab, openableMiniAppById, openTab, t, updateTab]
|
||||
)
|
||||
|
||||
// All per-type sidebar knowledge (icon, label, route, active-match, open, remove)
|
||||
// lives in the variant registry; the container only supplies the runtime context.
|
||||
const variantContext = useMemo<SidebarVariantContext>(
|
||||
() => ({
|
||||
t,
|
||||
defaultPaintingProvider,
|
||||
installedMiniApps: openableMiniAppById,
|
||||
isRequiredApp: (id) => REQUIRED_SIDEBAR_FAVORITE_SET.has(id),
|
||||
openApp: handleNavigate,
|
||||
openMiniApp: handleOpenMiniAppTab,
|
||||
removeApp: handleRemoveSidebarFavorite,
|
||||
removeMiniApp
|
||||
}),
|
||||
[
|
||||
t,
|
||||
defaultPaintingProvider,
|
||||
openableMiniAppById,
|
||||
handleNavigate,
|
||||
handleOpenMiniAppTab,
|
||||
handleRemoveSidebarFavorite,
|
||||
removeMiniApp
|
||||
]
|
||||
)
|
||||
|
||||
// One continuous list: built-in apps and mini apps interleaved in their stored
|
||||
// favorites order. Unrenderable rows (no route/icon, or an uninstalled mini app)
|
||||
// are dropped here but stay in the preference.
|
||||
const entries = useMemo(
|
||||
() => favorites.flatMap((favorite) => resolveSidebarEntry(favorite, variantContext) ?? []),
|
||||
[favorites, variantContext]
|
||||
)
|
||||
|
||||
// A single drag reorders the whole mixed list. arrayMove yields the new entry
|
||||
// order; map each entry back to its favorite by key and persist. The sidebar owns
|
||||
// its order entirely through `ui.sidebar.favorites` and never touches order keys.
|
||||
const handleReorder = useCallback(
|
||||
({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) => {
|
||||
const byKey = new Map(favorites.map((favorite) => [getSidebarFavoriteKey(favorite), favorite]))
|
||||
const nextFavorites = arrayMove(entries, oldIndex, newIndex).flatMap((entry) => {
|
||||
const favorite = byKey.get(entry.key)
|
||||
return favorite ? [favorite] : []
|
||||
})
|
||||
reorderFavorites(nextFavorites)
|
||||
},
|
||||
[entries, favorites, reorderFavorites]
|
||||
)
|
||||
|
||||
// Common props shared between normal and floating sidebar
|
||||
const sidebarProps = {
|
||||
activeItem,
|
||||
items,
|
||||
entries,
|
||||
active: { activeItem, activeTabId: activeMiniAppId },
|
||||
title: sidebarUser.name,
|
||||
logo: sidebarLogo,
|
||||
actions: (footerLayout: SidebarVisibleLayout) => (
|
||||
<SidebarShellActions layout={footerLayout} onSettingsClick={handleOpenSettingsTab} />
|
||||
),
|
||||
dockedTabs: [],
|
||||
onItemClick: handleNavigate,
|
||||
onCloseDockedTab: noop
|
||||
onEntriesReorder: handleReorder
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { MockUseCacheUtils } from '@test-mocks/renderer/useCache'
|
||||
import { MockUseDataApiUtils } from '@test-mocks/renderer/useDataApi'
|
||||
import { MockUsePreferenceUtils } from '@test-mocks/renderer/usePreference'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -70,7 +71,8 @@ vi.mock('@renderer/hooks/tab', () => ({
|
||||
},
|
||||
openTab: vi.fn(),
|
||||
updateTab: vi.fn()
|
||||
})
|
||||
}),
|
||||
useOptionalTabsContext: () => null
|
||||
}))
|
||||
|
||||
vi.mock('../../Popups/UserPopup', () => ({
|
||||
@@ -90,7 +92,9 @@ describe('Sidebar language refresh', () => {
|
||||
languageState.language = 'en-US'
|
||||
MockUsePreferenceUtils.resetMocks()
|
||||
MockUseCacheUtils.resetMocks()
|
||||
MockUsePreferenceUtils.setPreferenceValue('ui.sidebar.favorites', ['assistants'])
|
||||
MockUseDataApiUtils.resetMocks()
|
||||
MockUseDataApiUtils.mockQueryData('/mini-apps', [])
|
||||
MockUsePreferenceUtils.setPreferenceValue('ui.sidebar.favorites', [{ type: 'app', id: 'assistants' }])
|
||||
MockUsePreferenceUtils.setPreferenceValue('feature.paintings.default_provider', 'zhipu')
|
||||
MockUseCacheUtils.setPersistCacheValue('ui.sidebar.width', 170)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SidebarAppId } from '@renderer/utils/sidebar'
|
||||
import type { SidebarFavoriteItem } from '@shared/data/preference/preferenceTypes'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -15,6 +17,13 @@ type FakeTab = {
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type FakeMiniApp = {
|
||||
appId: string
|
||||
name: string
|
||||
logo?: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
emitResourceListReveal: vi.fn(),
|
||||
openTab: vi.fn(),
|
||||
@@ -27,10 +36,17 @@ const mocks = vi.hoisted(() => ({
|
||||
title: 'Chat'
|
||||
} as FakeTab | null,
|
||||
setSidebarWidth: vi.fn(),
|
||||
setSidebarFavorites: vi.fn(() => Promise.resolve()),
|
||||
reorderMiniAppsByStatus: vi.fn(() => Promise.resolve()),
|
||||
showUserPopup: vi.fn(),
|
||||
sidebarWidth: 50,
|
||||
tabs: [] as FakeTab[],
|
||||
sidebarFavorites: ['assistants'] as string[]
|
||||
sidebarFavorites: [{ type: 'app', id: 'assistants' }] as SidebarFavoriteItem[],
|
||||
sidebarMiniAppFavorites: [] as SidebarFavoriteItem[],
|
||||
allApps: [] as FakeMiniApp[],
|
||||
visibleMiniApps: null as FakeMiniApp[] | null,
|
||||
pinnedMiniApps: [] as FakeMiniApp[],
|
||||
onEntriesReorder: undefined as ((event: { oldIndex: number; newIndex: number }) => void) | undefined
|
||||
}))
|
||||
|
||||
vi.mock('@data/hooks/useCache', () => ({
|
||||
@@ -46,7 +62,8 @@ vi.mock('@data/hooks/useCache', () => ({
|
||||
vi.mock('@data/hooks/usePreference', () => ({
|
||||
usePreference: (key: string) => {
|
||||
if (key === 'app.user.name') return ['JD']
|
||||
if (key === 'ui.sidebar.favorites') return [mocks.sidebarFavorites]
|
||||
if (key === 'ui.sidebar.favorites')
|
||||
return [[...mocks.sidebarFavorites, ...mocks.sidebarMiniAppFavorites], mocks.setSidebarFavorites]
|
||||
return [undefined]
|
||||
}
|
||||
}))
|
||||
@@ -55,6 +72,14 @@ vi.mock('@renderer/hooks/useAvatar', () => ({
|
||||
default: () => undefined
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/hooks/useMiniApps', () => ({
|
||||
useMiniApps: () => ({
|
||||
allApps: mocks.allApps,
|
||||
miniApps: mocks.visibleMiniApps ?? mocks.allApps,
|
||||
pinned: mocks.pinnedMiniApps,
|
||||
reorderMiniAppsByStatus: mocks.reorderMiniAppsByStatus
|
||||
})
|
||||
}))
|
||||
vi.mock('@renderer/i18n/label', () => ({
|
||||
getSidebarIconLabelKey: (icon: string) =>
|
||||
({
|
||||
@@ -117,14 +142,28 @@ vi.mock('../../layout/ShellTabBarActions', () => ({
|
||||
)
|
||||
}))
|
||||
|
||||
type MockSidebarEntry = {
|
||||
key: string
|
||||
label: string
|
||||
isActive: (active: { activeItem: string; activeTabId?: string }) => boolean
|
||||
onOpen: () => void
|
||||
contextMenuItems?: Array<{ id: string; label: string; enabled?: boolean; onSelect?: () => void }>
|
||||
}
|
||||
|
||||
const parseEntryKey = (key: string) => {
|
||||
const idx = key.indexOf(':')
|
||||
return { type: key.slice(0, idx), id: key.slice(idx + 1) }
|
||||
}
|
||||
|
||||
vi.mock('../../Sidebar', () => ({
|
||||
Sidebar: ({
|
||||
isFloating,
|
||||
isFloatingClosing,
|
||||
onDismiss,
|
||||
onHoverChange,
|
||||
onItemClick,
|
||||
items,
|
||||
onEntriesReorder,
|
||||
active,
|
||||
entries,
|
||||
title,
|
||||
logo,
|
||||
user,
|
||||
@@ -134,7 +173,8 @@ vi.mock('../../Sidebar', () => ({
|
||||
}: {
|
||||
isFloating?: boolean
|
||||
isFloatingClosing?: boolean
|
||||
items?: Array<{ id: string; label: string }>
|
||||
active?: { activeItem: string; activeTabId?: string }
|
||||
entries?: MockSidebarEntry[]
|
||||
title?: string
|
||||
logo?: ReactNode
|
||||
user?: unknown
|
||||
@@ -143,9 +183,15 @@ vi.mock('../../Sidebar', () => ({
|
||||
onResizePreview?: (width: number | null) => void
|
||||
onDismiss?: () => void
|
||||
onHoverChange?: (hovering: boolean) => void
|
||||
onItemClick?: (id: string) => void
|
||||
}) =>
|
||||
isFloating ? (
|
||||
onEntriesReorder?: (event: { oldIndex: number; newIndex: number }) => void
|
||||
}) => {
|
||||
mocks.onEntriesReorder = onEntriesReorder
|
||||
// Entries are type-agnostic resolved rows; the tests still assert per-type
|
||||
// testids, so recover the type/id from the stable `entry.key` (`${type}:${id}`).
|
||||
const activeState = active ?? { activeItem: '' }
|
||||
const items = entries?.filter((entry) => parseEntryKey(entry.key).type === 'app')
|
||||
const dockedTabs = entries?.filter((entry) => parseEntryKey(entry.key).type === 'mini_app')
|
||||
return isFloating ? (
|
||||
<div
|
||||
className={isFloatingClosing ? 'slide-out-to-left-2 animate-out' : 'slide-in-from-left-2 animate-in'}
|
||||
data-testid="floating-sidebar">
|
||||
@@ -167,17 +213,52 @@ vi.mock('../../Sidebar', () => ({
|
||||
<div data-testid="ui-sidebar" data-width={width} />
|
||||
<div data-testid="sidebar-items">
|
||||
{items?.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-testid={`sidebar-item-${item.id}`}
|
||||
onClick={() => onItemClick?.(item.id)}>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
<div key={item.key}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`sidebar-item-${parseEntryKey(item.key).id}`}
|
||||
onClick={() => item.onOpen()}>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
{item.contextMenuItems?.map((menuItem) => (
|
||||
<button
|
||||
key={menuItem.id}
|
||||
type="button"
|
||||
data-testid={`sidebar-menu-${menuItem.id}`}
|
||||
disabled={menuItem.enabled === false}
|
||||
onClick={menuItem.onSelect}>
|
||||
{menuItem.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div data-testid="sidebar-mini-app-section">
|
||||
{dockedTabs?.map((miniTab) => (
|
||||
<div key={miniTab.key}>
|
||||
<button
|
||||
type="button"
|
||||
data-active={miniTab.isActive(activeState) ? 'true' : 'false'}
|
||||
data-testid={`sidebar-mini-app-${parseEntryKey(miniTab.key).id}`}
|
||||
onClick={() => miniTab.onOpen()}>
|
||||
{miniTab.label}
|
||||
</button>
|
||||
{miniTab.contextMenuItems?.map((menuItem) => (
|
||||
<button
|
||||
key={menuItem.id}
|
||||
type="button"
|
||||
data-testid={`sidebar-menu-${menuItem.id}`}
|
||||
disabled={menuItem.enabled === false}
|
||||
onClick={menuItem.onSelect}>
|
||||
{menuItem.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
@@ -193,10 +274,18 @@ import { resolveSidebarAppTabEntryUrl } from '@renderer/utils/sidebar'
|
||||
|
||||
import Sidebar from '../Sidebar'
|
||||
|
||||
const appFavorite = (id: SidebarAppId): SidebarFavoriteItem => ({ type: 'app', id })
|
||||
const miniAppFavorite = (id: string): SidebarFavoriteItem => ({ type: 'mini_app', id })
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
mocks.sidebarFavorites = ['assistants']
|
||||
mocks.sidebarFavorites = [appFavorite('assistants')]
|
||||
mocks.sidebarMiniAppFavorites = []
|
||||
mocks.setSidebarFavorites.mockReset()
|
||||
mocks.setSidebarFavorites.mockResolvedValue(undefined)
|
||||
mocks.reorderMiniAppsByStatus.mockReset()
|
||||
mocks.reorderMiniAppsByStatus.mockResolvedValue(undefined)
|
||||
mocks.activeTab = {
|
||||
id: 'chat',
|
||||
type: 'route',
|
||||
@@ -204,6 +293,9 @@ afterEach(() => {
|
||||
title: 'Chat'
|
||||
}
|
||||
mocks.tabs = []
|
||||
mocks.allApps = []
|
||||
mocks.visibleMiniApps = null
|
||||
mocks.pinnedMiniApps = []
|
||||
mocks.sidebarWidth = 50
|
||||
vi.useRealTimers()
|
||||
document.documentElement.style.removeProperty('--sidebar-width')
|
||||
@@ -256,7 +348,7 @@ describe('app Sidebar', () => {
|
||||
})
|
||||
|
||||
it('renders sidebar menu items in visible preference order', () => {
|
||||
mocks.sidebarFavorites = ['translate', 'assistants', 'agents']
|
||||
mocks.sidebarFavorites = [appFavorite('translate'), appFavorite('assistants'), appFavorite('agents')]
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
@@ -266,8 +358,233 @@ describe('app Sidebar', () => {
|
||||
expect(labels).toEqual(['Translate', 'Chat', 'Work'])
|
||||
})
|
||||
|
||||
it('removes a sidebar app favorite from the context menu', () => {
|
||||
mocks.sidebarFavorites = [appFavorite('assistants'), appFavorite('knowledge'), appFavorite('files')]
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
expect(screen.getByTestId('sidebar-menu-sidebar.remove-app.knowledge')).toHaveTextContent(
|
||||
'launchpad.unpin_from_sidebar'
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('sidebar-menu-sidebar.remove-app.knowledge'))
|
||||
|
||||
expect(mocks.setSidebarFavorites).toHaveBeenCalledWith([appFavorite('assistants'), appFavorite('files')])
|
||||
})
|
||||
|
||||
it('keeps required sidebar favorites protected in the context menu', () => {
|
||||
render(<Sidebar />)
|
||||
|
||||
expect(screen.getByTestId('sidebar-menu-sidebar.remove-app.assistants')).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByTestId('sidebar-menu-sidebar.remove-app.assistants'))
|
||||
|
||||
expect(mocks.setSidebarFavorites).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders favorite mini apps directly in the sidebar mini app section', () => {
|
||||
mocks.sidebarFavorites = [appFavorite('assistants'), appFavorite('mini_app')]
|
||||
mocks.sidebarMiniAppFavorites = [miniAppFavorite('calculator'), miniAppFavorite('weather')]
|
||||
mocks.allApps = [
|
||||
{ appId: 'calculator', name: 'Calculator', logo: 'calculator-logo', url: 'https://calc.example' },
|
||||
{ appId: 'weather', name: 'Weather', logo: 'weather-logo', url: 'https://weather.example' }
|
||||
]
|
||||
mocks.activeTab = {
|
||||
id: 'calculator-tab',
|
||||
type: 'route',
|
||||
url: '/app/mini-app/calculator',
|
||||
title: 'Calculator'
|
||||
}
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
expect(screen.getByTestId('sidebar-mini-app-section')).toContainElement(
|
||||
screen.getByTestId('sidebar-mini-app-calculator')
|
||||
)
|
||||
expect(screen.getByTestId('sidebar-mini-app-calculator')).toHaveTextContent('Calculator')
|
||||
expect(screen.getByTestId('sidebar-mini-app-calculator')).toHaveAttribute('data-active', 'true')
|
||||
expect(screen.getByTestId('sidebar-mini-app-weather')).toHaveTextContent('Weather')
|
||||
expect(
|
||||
Array.from(screen.getByTestId('sidebar-mini-app-section').querySelectorAll('button')).map(
|
||||
(button) => button.textContent
|
||||
)
|
||||
).toEqual(['Calculator', 'launchpad.unpin_from_sidebar', 'Weather', 'launchpad.unpin_from_sidebar'])
|
||||
})
|
||||
|
||||
it('removes a sidebar mini app favorite from the context menu', () => {
|
||||
mocks.sidebarFavorites = [appFavorite('assistants'), appFavorite('mini_app')]
|
||||
mocks.sidebarMiniAppFavorites = [miniAppFavorite('calculator'), miniAppFavorite('weather')]
|
||||
mocks.allApps = [
|
||||
{ appId: 'calculator', name: 'Calculator', logo: 'calculator-logo', url: 'https://calc.example' },
|
||||
{ appId: 'weather', name: 'Weather', logo: 'weather-logo', url: 'https://weather.example' }
|
||||
]
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('sidebar-menu-sidebar.remove-mini-app.calculator'))
|
||||
|
||||
expect(mocks.setSidebarFavorites).toHaveBeenCalledWith([
|
||||
appFavorite('assistants'),
|
||||
appFavorite('mini_app'),
|
||||
miniAppFavorite('weather')
|
||||
])
|
||||
})
|
||||
|
||||
it('reorders sidebar favorites through a single mixed drag', () => {
|
||||
mocks.sidebarFavorites = [appFavorite('assistants'), appFavorite('knowledge'), appFavorite('files')]
|
||||
mocks.sidebarMiniAppFavorites = [miniAppFavorite('calculator')]
|
||||
mocks.allApps = [{ appId: 'calculator', name: 'Calculator', logo: 'calculator-logo', url: 'https://calc.example' }]
|
||||
|
||||
render(<Sidebar />)
|
||||
// Mixed list is [assistants, knowledge, files, calculator]; drag files to front.
|
||||
act(() => mocks.onEntriesReorder?.({ oldIndex: 2, newIndex: 0 }))
|
||||
|
||||
expect(mocks.setSidebarFavorites).toHaveBeenCalledWith([
|
||||
appFavorite('files'),
|
||||
appFavorite('assistants'),
|
||||
appFavorite('knowledge'),
|
||||
miniAppFavorite('calculator')
|
||||
])
|
||||
})
|
||||
|
||||
it('reorders sidebar mini apps through favorites without touching the mini app order key', () => {
|
||||
mocks.sidebarFavorites = [appFavorite('assistants'), appFavorite('mini_app')]
|
||||
mocks.sidebarMiniAppFavorites = [miniAppFavorite('calculator'), miniAppFavorite('weather')]
|
||||
mocks.allApps = [
|
||||
{ appId: 'calculator', name: 'Calculator', logo: 'calculator-logo', url: 'https://calc.example' },
|
||||
{ appId: 'weather', name: 'Weather', logo: 'weather-logo', url: 'https://weather.example' }
|
||||
]
|
||||
|
||||
render(<Sidebar />)
|
||||
// Mixed list is [assistants, mini_app, calculator, weather]; drag weather above calculator.
|
||||
act(() => mocks.onEntriesReorder?.({ oldIndex: 3, newIndex: 2 }))
|
||||
|
||||
expect(mocks.setSidebarFavorites).toHaveBeenCalledWith([
|
||||
appFavorite('assistants'),
|
||||
appFavorite('mini_app'),
|
||||
miniAppFavorite('weather'),
|
||||
miniAppFavorite('calculator')
|
||||
])
|
||||
// The sidebar owns its order through favorites only — the mini app order key
|
||||
// (shared with the mini apps grid) is left untouched.
|
||||
expect(mocks.reorderMiniAppsByStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drag-reorders a mini app above a built-in app, interleaving the two types', () => {
|
||||
mocks.sidebarFavorites = [appFavorite('assistants'), appFavorite('mini_app')]
|
||||
mocks.sidebarMiniAppFavorites = [miniAppFavorite('calculator')]
|
||||
mocks.allApps = [{ appId: 'calculator', name: 'Calculator', logo: 'calculator-logo', url: 'https://calc.example' }]
|
||||
|
||||
render(<Sidebar />)
|
||||
// Mixed list is [assistants, mini_app, calculator]; drag calculator to the very top.
|
||||
act(() => mocks.onEntriesReorder?.({ oldIndex: 2, newIndex: 0 }))
|
||||
|
||||
expect(mocks.setSidebarFavorites).toHaveBeenCalledWith([
|
||||
miniAppFavorite('calculator'),
|
||||
appFavorite('assistants'),
|
||||
appFavorite('mini_app')
|
||||
])
|
||||
})
|
||||
|
||||
it('does not render mini apps unless they are sidebar favorites', () => {
|
||||
mocks.sidebarFavorites = [appFavorite('assistants'), appFavorite('mini_app')]
|
||||
mocks.allApps = [{ appId: 'calculator', name: 'Calculator', logo: 'calculator-logo', url: 'https://calc.example' }]
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
expect(screen.queryByTestId('sidebar-mini-app-calculator')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('drops stale mini app ids from sidebar favorites', () => {
|
||||
mocks.sidebarFavorites = [appFavorite('assistants'), appFavorite('mini_app')]
|
||||
mocks.sidebarMiniAppFavorites = [miniAppFavorite('calculator'), miniAppFavorite('stale')]
|
||||
mocks.allApps = [{ appId: 'calculator', name: 'Calculator', logo: 'calculator-logo', url: 'https://calc.example' }]
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
expect(screen.getByTestId('sidebar-mini-app-calculator')).toHaveTextContent('Calculator')
|
||||
expect(screen.queryByTestId('sidebar-mini-app-stale')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render hidden mini apps left in sidebar favorites', () => {
|
||||
mocks.sidebarFavorites = [appFavorite('assistants'), appFavorite('mini_app')]
|
||||
mocks.sidebarMiniAppFavorites = [miniAppFavorite('calculator')]
|
||||
mocks.allApps = [{ appId: 'calculator', name: 'Calculator', logo: 'calculator-logo', url: 'https://calc.example' }]
|
||||
mocks.visibleMiniApps = []
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
expect(screen.queryByTestId('sidebar-mini-app-calculator')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reuses the active tab from the sidebar mini app section', () => {
|
||||
mocks.sidebarFavorites = [appFavorite('assistants'), appFavorite('mini_app')]
|
||||
mocks.sidebarMiniAppFavorites = [miniAppFavorite('calculator')]
|
||||
mocks.allApps = [{ appId: 'calculator', name: 'Calculator', logo: 'calculator-logo', url: 'https://calc.example' }]
|
||||
mocks.activeTab = {
|
||||
id: 'chat',
|
||||
type: 'route',
|
||||
url: '/app/chat?topicId=t-1',
|
||||
title: 'Topic',
|
||||
icon: 'emoji:🍒',
|
||||
metadata: { instanceAppId: 'assistants', instanceKey: 't-1', keep: true }
|
||||
}
|
||||
|
||||
render(<Sidebar />)
|
||||
fireEvent.click(screen.getByTestId('sidebar-mini-app-calculator'))
|
||||
|
||||
expect(mocks.updateTab).toHaveBeenCalledWith('chat', {
|
||||
url: '/app/mini-app/calculator',
|
||||
title: 'Calculator',
|
||||
icon: 'calculator-logo',
|
||||
metadata: { keep: true }
|
||||
})
|
||||
expect(mocks.openTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when the active tab is already on the target mini app route', () => {
|
||||
mocks.sidebarFavorites = [appFavorite('assistants'), appFavorite('mini_app')]
|
||||
mocks.sidebarMiniAppFavorites = [miniAppFavorite('calculator')]
|
||||
mocks.allApps = [{ appId: 'calculator', name: 'Calculator', logo: 'calculator-logo', url: 'https://calc.example' }]
|
||||
mocks.activeTab = {
|
||||
id: 'calculator-tab',
|
||||
type: 'route',
|
||||
url: '/app/mini-app/calculator',
|
||||
title: 'Calculator'
|
||||
}
|
||||
|
||||
render(<Sidebar />)
|
||||
fireEvent.click(screen.getByTestId('sidebar-mini-app-calculator'))
|
||||
|
||||
expect(mocks.updateTab).not.toHaveBeenCalled()
|
||||
expect(mocks.openTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens a forced mini app tab when the active tab is pinned', () => {
|
||||
mocks.sidebarFavorites = [appFavorite('assistants'), appFavorite('mini_app')]
|
||||
mocks.sidebarMiniAppFavorites = [miniAppFavorite('calculator')]
|
||||
mocks.allApps = [{ appId: 'calculator', name: 'Calculator', logo: 'calculator-logo', url: 'https://calc.example' }]
|
||||
mocks.activeTab = {
|
||||
id: 'chat',
|
||||
type: 'route',
|
||||
url: '/app/chat',
|
||||
title: 'Chat',
|
||||
isPinned: true
|
||||
}
|
||||
|
||||
render(<Sidebar />)
|
||||
fireEvent.click(screen.getByTestId('sidebar-mini-app-calculator'))
|
||||
|
||||
expect(mocks.openTab).toHaveBeenCalledWith('/app/mini-app/calculator', {
|
||||
forceNew: true,
|
||||
title: 'Calculator',
|
||||
icon: 'calculator-logo'
|
||||
})
|
||||
expect(mocks.updateTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when the active tab is already on the target route', () => {
|
||||
mocks.sidebarFavorites = ['agents']
|
||||
mocks.sidebarFavorites = [appFavorite('agents')]
|
||||
mocks.activeTab = {
|
||||
id: 'agents',
|
||||
type: 'route',
|
||||
@@ -284,7 +601,7 @@ describe('app Sidebar', () => {
|
||||
})
|
||||
|
||||
it('reuses the active tab even when another sidebar app tab exists', () => {
|
||||
mocks.sidebarFavorites = ['agents']
|
||||
mocks.sidebarFavorites = [appFavorite('agents')]
|
||||
mocks.activeTab = {
|
||||
id: 'chat',
|
||||
type: 'route',
|
||||
@@ -308,7 +625,7 @@ describe('app Sidebar', () => {
|
||||
})
|
||||
|
||||
it('clears stale instance metadata when reusing the active tab', () => {
|
||||
mocks.sidebarFavorites = ['translate']
|
||||
mocks.sidebarFavorites = [appFavorite('translate')]
|
||||
mocks.activeTab = {
|
||||
id: 'chat',
|
||||
type: 'route',
|
||||
@@ -332,7 +649,7 @@ describe('app Sidebar', () => {
|
||||
})
|
||||
|
||||
it('reuses the active tab for single-policy routes too', () => {
|
||||
mocks.sidebarFavorites = ['translate']
|
||||
mocks.sidebarFavorites = [appFavorite('translate')]
|
||||
mocks.activeTab = {
|
||||
id: 'chat',
|
||||
type: 'route',
|
||||
@@ -353,7 +670,7 @@ describe('app Sidebar', () => {
|
||||
})
|
||||
|
||||
it('opens a forced tab when the active tab is pinned', () => {
|
||||
mocks.sidebarFavorites = ['agents']
|
||||
mocks.sidebarFavorites = [appFavorite('agents')]
|
||||
mocks.activeTab = {
|
||||
id: 'chat',
|
||||
type: 'route',
|
||||
@@ -373,7 +690,7 @@ describe('app Sidebar', () => {
|
||||
})
|
||||
|
||||
it('opens a forced tab when there is no active tab', () => {
|
||||
mocks.sidebarFavorites = ['files']
|
||||
mocks.sidebarFavorites = [appFavorite('files')]
|
||||
mocks.activeTab = null
|
||||
mocks.openTab.mockReturnValue('files-new')
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OpenClawSidebarIcon } from '@renderer/components/Icons/SvgIcon'
|
||||
import type { SidebarMenuItem } from '@renderer/components/Sidebar/types'
|
||||
import type { SidebarFavorite } from '@shared/data/preference/preferenceTypes'
|
||||
import type { SidebarAppId } from '@renderer/utils/sidebar'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Code,
|
||||
FileSearch,
|
||||
@@ -15,12 +15,12 @@ import {
|
||||
} from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Icon component for each sidebar app. Keyed by the `SidebarFavorite` union so the
|
||||
* Icon component for each built-in sidebar app. Keyed by the `SidebarAppId` union so the
|
||||
* compiler enforces full coverage — adding a new sidebar app id without an icon
|
||||
* here is a type error. Kept in the component layer because the values are React
|
||||
* components; the navigation data and logic live in `@renderer/utils/sidebar`.
|
||||
*/
|
||||
export const SIDEBAR_ICON_COMPONENTS: Record<SidebarFavorite, SidebarMenuItem['icon']> = {
|
||||
export const SIDEBAR_ICON_COMPONENTS: Record<SidebarAppId, LucideIcon> = {
|
||||
assistants: MessageSquare,
|
||||
agents: MousePointerClick,
|
||||
paintings: Palette,
|
||||
|
||||
121
src/renderer/components/app/sidebarVariants.tsx
Normal file
121
src/renderer/components/app/sidebarVariants.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { getSidebarIconLabelKey } from '@renderer/i18n/label'
|
||||
import type { SidebarAppId } from '@renderer/utils/sidebar'
|
||||
import { getSidebarFavoriteKey, getSidebarMenuPath } from '@renderer/utils/sidebar'
|
||||
import type { SidebarFavoriteItem } from '@shared/data/preference/preferenceTypes'
|
||||
import type { MiniApp } from '@shared/data/types/miniApp'
|
||||
|
||||
import { MiniAppIcon } from '../Sidebar/primitives'
|
||||
import type { ResolvedSidebarEntry } from '../Sidebar/types'
|
||||
import { SIDEBAR_ICON_COMPONENTS } from './sidebarIcons'
|
||||
|
||||
/** Exhaustiveness guard: a new `SidebarFavoriteItem` type must add a `case` below. */
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unhandled sidebar favorite variant: ${JSON.stringify(value)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime context a variant needs to resolve a favorite into a rendered row:
|
||||
* i18n, route inputs, installed mini app data, and the open/remove callbacks the
|
||||
* container owns.
|
||||
*/
|
||||
export interface SidebarVariantContext {
|
||||
t: (key: string) => string
|
||||
defaultPaintingProvider: string
|
||||
installedMiniApps: Map<string, MiniApp>
|
||||
isRequiredApp: (id: SidebarAppId) => boolean
|
||||
openApp: (id: SidebarAppId) => void
|
||||
openMiniApp: (id: string) => void
|
||||
removeApp: (id: SidebarAppId) => void
|
||||
removeMiniApp: (id: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* One sidebar item type's whole behavior in a single object: how a stored
|
||||
* favorite of that type resolves into a rendered, type-agnostic row (icon, label,
|
||||
* active-match, open action, context menu), or `null` when it is not renderable
|
||||
* (missing icon/route, or an uninstalled mini app). Adding a new sidebar item type
|
||||
* = one new descriptor here plus a `case` in `resolveSidebarEntry`.
|
||||
*/
|
||||
interface SidebarVariantDescriptor<T extends SidebarFavoriteItem> {
|
||||
resolve: (item: T, ctx: SidebarVariantContext) => ResolvedSidebarEntry | null
|
||||
}
|
||||
|
||||
const appVariant: SidebarVariantDescriptor<Extract<SidebarFavoriteItem, { type: 'app' }>> = {
|
||||
resolve: (item, ctx) => {
|
||||
const id = item.id
|
||||
const path = getSidebarMenuPath(id, ctx.defaultPaintingProvider)
|
||||
const Icon = SIDEBAR_ICON_COMPONENTS[id]
|
||||
// Unrenderable app (no route or no icon) is dropped from the list but stays in
|
||||
// the preference.
|
||||
if (!path || !Icon) return null
|
||||
|
||||
return {
|
||||
key: getSidebarFavoriteKey(item),
|
||||
label: ctx.t(getSidebarIconLabelKey(id)),
|
||||
renderIcon: (size) => <Icon size={size} strokeWidth={1.6} />,
|
||||
isActive: (active) => active.activeItem === id,
|
||||
onOpen: () => ctx.openApp(id),
|
||||
contextMenuItems: [
|
||||
{
|
||||
type: 'item',
|
||||
id: `sidebar.remove-app.${id}`,
|
||||
label: ctx.t('launchpad.unpin_from_sidebar'),
|
||||
enabled: !ctx.isRequiredApp(id),
|
||||
onSelect: () => ctx.removeApp(id)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const miniAppVariant: SidebarVariantDescriptor<Extract<SidebarFavoriteItem, { type: 'mini_app' }>> = {
|
||||
resolve: (item, ctx) => {
|
||||
const app = ctx.installedMiniApps.get(item.id)
|
||||
// Stale mini app (no matching installed app) is dropped from the list but stays
|
||||
// in the preference.
|
||||
if (!app) return null
|
||||
|
||||
const title = app.nameKey ? ctx.t(app.nameKey) : app.name
|
||||
const tab = {
|
||||
title,
|
||||
miniApp: { id: app.appId, logo: app.logo, url: app.url }
|
||||
}
|
||||
|
||||
return {
|
||||
key: getSidebarFavoriteKey(item),
|
||||
label: title,
|
||||
renderIcon: (_size, miniAppSize) => <MiniAppIcon tab={tab} size={miniAppSize} />,
|
||||
isActive: (active) => active.activeTabId === app.appId,
|
||||
onOpen: () => ctx.openMiniApp(app.appId),
|
||||
contextMenuItems: [
|
||||
{
|
||||
type: 'item',
|
||||
id: `sidebar.remove-mini-app.${app.appId}`,
|
||||
label: ctx.t('launchpad.unpin_from_sidebar'),
|
||||
onSelect: () => ctx.removeMiniApp(app.appId)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one stored favorite into a rendered row via its variant descriptor, or
|
||||
* `null` when it is not renderable. The single dispatch here is the only place
|
||||
* that switches on the favorite type; every type-specific detail lives in the
|
||||
* descriptor above. The `assertNever` default makes adding a `SidebarFavoriteItem`
|
||||
* type a compile error until a `case` is added.
|
||||
*/
|
||||
export function resolveSidebarEntry(
|
||||
favorite: SidebarFavoriteItem,
|
||||
ctx: SidebarVariantContext
|
||||
): ResolvedSidebarEntry | null {
|
||||
switch (favorite.type) {
|
||||
case 'app':
|
||||
return appVariant.resolve(favorite, ctx)
|
||||
case 'mini_app':
|
||||
return miniAppVariant.resolve(favorite, ctx)
|
||||
default:
|
||||
return assertNever(favorite)
|
||||
}
|
||||
}
|
||||
@@ -58,10 +58,9 @@ export function ResourceListActionContextMenu<T extends ResourceListItemBase, TA
|
||||
|
||||
const extraItems = useMemo(() => actionsToCommandMenuExtraItems(actions, runAction), [actions, runAction])
|
||||
|
||||
// Set the active context-menu item on the right-click itself, not via the cherry-only
|
||||
// `onOpenChange`: the native menu path opens through `onContextMenu` + `showNativePopupMenu`
|
||||
// and never fires `onOpenChange`, so otherwise native-mode right-clicks would leave the
|
||||
// ResourceList pointing at a stale item. This wrapper fires for both presentation modes.
|
||||
// Set the active context-menu item on the right-click itself, not via `onOpenChange`:
|
||||
// open-change does not include the clicked row, while this wrapper fires for both
|
||||
// Cherry and native presentation modes.
|
||||
const markActiveItem = useCallback(() => listActions.openContextMenu(getItemId(item)), [listActions, getItemId, item])
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,9 +7,8 @@ import { ResourceListActionContextMenu } from '../ResourceListActionContextMenu'
|
||||
const openContextMenu = vi.fn()
|
||||
|
||||
// The native/cherry presentation-mode branching lives in CommandMenus and is its own concern; the
|
||||
// point of this test is the *mode-independent* fix — the wrapper onContextMenu that this component
|
||||
// puts around the trigger children — so the mock just renders the children inside a span (like the
|
||||
// native branch's wrapper) and we assert the right-click bubbles through it.
|
||||
// point of this test is the *mode-independent* wrapper onContextMenu that identifies the clicked row.
|
||||
// The mock just renders the children inside a span and we assert the right-click bubbles through it.
|
||||
vi.mock('@renderer/components/command', () => ({
|
||||
CommandContextMenu: ({ children }: { children: ReactNode }) => (
|
||||
<span data-testid="command-context-menu">{children}</span>
|
||||
@@ -35,9 +34,7 @@ describe('ResourceListActionContextMenu', () => {
|
||||
</ResourceListActionContextMenu>
|
||||
)
|
||||
|
||||
// The native menu path opens through onContextMenu and never fires onOpenChange, so relying on
|
||||
// onOpenChange would skip native-mode right-clicks. The wrapper onContextMenu fires on the
|
||||
// right-click for both modes — verify it bubbles up from the row and sets the active item.
|
||||
// The wrapper onContextMenu fires on the right-click for both modes and carries the row identity.
|
||||
fireEvent.contextMenu(screen.getByText('Row'))
|
||||
|
||||
expect(openContextMenu).toHaveBeenCalledWith('topic-7')
|
||||
|
||||
@@ -90,15 +90,25 @@ const MessageErrorInfo: React.FC<{
|
||||
(error as Record<string, unknown> | undefined)?.status ?? (error as Record<string, unknown> | undefined)?.statusCode
|
||||
const errorProviderId = (error as Record<string, unknown> | undefined)?.providerId as string | undefined
|
||||
const errorModelId = (error as Record<string, unknown> | undefined)?.modelId as string | undefined
|
||||
const classificationStatus =
|
||||
typeof errorStatus === 'number' || typeof errorStatus === 'string' ? errorStatus : undefined
|
||||
|
||||
const providerId = getMessageListItemModel(message)?.provider ?? errorProviderId
|
||||
const classification = useMemo(
|
||||
() => classifyError(error, providerId),
|
||||
const classification = useMemo(() => {
|
||||
const classificationError: SerializedError = {
|
||||
name: null,
|
||||
message: errorMessage ?? null,
|
||||
stack: null,
|
||||
...(classificationStatus !== undefined
|
||||
? {
|
||||
status: classificationStatus,
|
||||
statusCode: classificationStatus
|
||||
}
|
||||
: {})
|
||||
}
|
||||
|
||||
// primitives instead of the `error` object reference; `classifyError`
|
||||
// only inspects fields covered by these scalars.
|
||||
[errorMessage, errorStatus, errorProviderId, providerId]
|
||||
)
|
||||
return classifyError(classificationError, providerId)
|
||||
}, [classificationStatus, errorMessage, providerId])
|
||||
|
||||
useEffect(() => {
|
||||
if (classification.category !== 'unknown' || !errorMessage || !error || !diagnoseMessageError) return
|
||||
|
||||
@@ -138,7 +138,7 @@ import {
|
||||
|
||||
const t = ((key: string) => key) as any
|
||||
|
||||
function createContext(overrides: Partial<MessageMenuBarActionContext> = {}): MessageMenuBarActionContext {
|
||||
function createActionContext(overrides: Partial<MessageMenuBarActionContext> = {}): MessageMenuBarActionContext {
|
||||
const baseActions = {
|
||||
copyText: vi.fn(),
|
||||
copyImage: vi.fn(),
|
||||
@@ -193,7 +193,7 @@ function createContext(overrides: Partial<MessageMenuBarActionContext> = {}): Me
|
||||
describe('messageMenuBarActions', () => {
|
||||
it('keeps write actions hidden when capabilities are absent', () => {
|
||||
const toolbarActions = resolveMessageMenuBarToolbarActions(
|
||||
createContext({
|
||||
createActionContext({
|
||||
message: {
|
||||
id: 'message-1',
|
||||
role: 'user',
|
||||
@@ -212,7 +212,7 @@ describe('messageMenuBarActions', () => {
|
||||
|
||||
it('keeps user edit toolbar action for root messages', () => {
|
||||
const toolbarActions = resolveMessageMenuBarToolbarActions(
|
||||
createContext({
|
||||
createActionContext({
|
||||
message: {
|
||||
id: 'message-1',
|
||||
role: 'user',
|
||||
@@ -234,7 +234,7 @@ describe('messageMenuBarActions', () => {
|
||||
|
||||
it('keeps user edit toolbar action for non-root messages', () => {
|
||||
const toolbarActions = resolveMessageMenuBarToolbarActions(
|
||||
createContext({
|
||||
createActionContext({
|
||||
message: {
|
||||
id: 'message-1',
|
||||
role: 'user',
|
||||
@@ -256,7 +256,7 @@ describe('messageMenuBarActions', () => {
|
||||
|
||||
it('keeps edit menu action for root messages', () => {
|
||||
const menuActions = resolveMessageMenuBarMenuActions(
|
||||
createContext({
|
||||
createActionContext({
|
||||
message: {
|
||||
id: 'message-1',
|
||||
role: 'user',
|
||||
@@ -278,7 +278,7 @@ describe('messageMenuBarActions', () => {
|
||||
|
||||
it('resolves assistant toolbar actions from capabilities', () => {
|
||||
const toolbarActions = resolveMessageMenuBarToolbarActions(
|
||||
createContext({
|
||||
createActionContext({
|
||||
actions: {
|
||||
deleteMessage: vi.fn(),
|
||||
exportToNotes: vi.fn(),
|
||||
@@ -312,7 +312,7 @@ describe('messageMenuBarActions', () => {
|
||||
|
||||
it('does not require confirmation before regenerating an assistant message', () => {
|
||||
const toolbarActions = resolveMessageMenuBarToolbarActions(
|
||||
createContext({
|
||||
createActionContext({
|
||||
actions: {
|
||||
regenerateMessage: vi.fn()
|
||||
} as MessageListActions
|
||||
@@ -324,7 +324,7 @@ describe('messageMenuBarActions', () => {
|
||||
|
||||
it('renders mention-model picker with a direct button trigger', () => {
|
||||
const renderRegenerateModelPicker = vi.fn(({ trigger }) => <div data-testid="model-picker">{trigger}</div>)
|
||||
const context = createContext({
|
||||
const context = createActionContext({
|
||||
actions: { renderRegenerateModelPicker } as unknown as MessageListActions
|
||||
})
|
||||
const action = resolveMessageMenuBarToolbarActions(context).find((item) => item.id === 'assistant-mention-model')
|
||||
@@ -355,7 +355,7 @@ describe('messageMenuBarActions', () => {
|
||||
it('keeps the more menu tooltip controlled while opening the menu with one click', () => {
|
||||
tooltipOpenValues.length = 0
|
||||
|
||||
const context = createContext()
|
||||
const context = createActionContext()
|
||||
const action = resolveMessageMenuBarToolbarActions(context).find((item) => item.id === 'more-menu')
|
||||
const executeAction = vi.fn()
|
||||
|
||||
@@ -401,7 +401,7 @@ describe('messageMenuBarActions', () => {
|
||||
it('suppresses the more menu tooltip after the menu closes until the trigger is left', () => {
|
||||
tooltipOpenValues.length = 0
|
||||
|
||||
const MessageMenuActionContext = createContext()
|
||||
const MessageMenuActionContext = createActionContext()
|
||||
const action = resolveMessageMenuBarToolbarActions(MessageMenuActionContext).find((item) => item.id === 'more-menu')
|
||||
|
||||
expect(action).toBeTruthy()
|
||||
@@ -450,7 +450,7 @@ describe('messageMenuBarActions', () => {
|
||||
it('keeps the translate tooltip controlled while opening the language menu with one click', () => {
|
||||
tooltipOpenValues.length = 0
|
||||
|
||||
const context = createContext({
|
||||
const context = createActionContext({
|
||||
actions: {
|
||||
translateMessage: vi.fn()
|
||||
} as unknown as MessageListActions,
|
||||
@@ -492,7 +492,7 @@ describe('messageMenuBarActions', () => {
|
||||
it('suppresses the translate tooltip after the language menu closes until a new trigger hover starts', () => {
|
||||
tooltipOpenValues.length = 0
|
||||
|
||||
const MessageMenuActionContext = createContext({
|
||||
const MessageMenuActionContext = createActionContext({
|
||||
actions: {
|
||||
translateMessage: vi.fn()
|
||||
} as unknown as MessageListActions,
|
||||
@@ -537,7 +537,7 @@ describe('messageMenuBarActions', () => {
|
||||
it('keeps session scope capability-driven for toolbar actions', () => {
|
||||
const sessionConfig = getMessageMenuBarConfig(TopicType.Session)
|
||||
const toolbarActions = resolveMessageMenuBarToolbarActions(
|
||||
createContext({
|
||||
createActionContext({
|
||||
actions: {
|
||||
deleteMessage: vi.fn(),
|
||||
exportToNotes: vi.fn(),
|
||||
@@ -555,7 +555,7 @@ describe('messageMenuBarActions', () => {
|
||||
|
||||
it('keeps menu actions capability-driven instead of filtering by session roots', () => {
|
||||
const menuActions = resolveMessageMenuBarMenuActions(
|
||||
createContext({
|
||||
createActionContext({
|
||||
actions: {
|
||||
exportMessageAsMarkdown: vi.fn(),
|
||||
saveTextFile: vi.fn(),
|
||||
@@ -584,7 +584,7 @@ describe('messageMenuBarActions', () => {
|
||||
|
||||
it('hides new branch from the latest message menu', () => {
|
||||
const menuActions = resolveMessageMenuBarMenuActions(
|
||||
createContext({
|
||||
createActionContext({
|
||||
actions: {
|
||||
startMessageBranch: vi.fn(),
|
||||
toggleMultiSelectMode: vi.fn()
|
||||
@@ -603,7 +603,7 @@ describe('messageMenuBarActions', () => {
|
||||
|
||||
it('hides new branch from user message menus', () => {
|
||||
const menuActions = resolveMessageMenuBarMenuActions(
|
||||
createContext({
|
||||
createActionContext({
|
||||
actions: {
|
||||
startMessageBranch: vi.fn(),
|
||||
toggleMultiSelectMode: vi.fn()
|
||||
@@ -623,7 +623,7 @@ describe('messageMenuBarActions', () => {
|
||||
|
||||
it('disables streaming-unsafe toolbar actions while keeping copy enabled', () => {
|
||||
const toolbarActions = resolveMessageMenuBarToolbarActions(
|
||||
createContext({
|
||||
createActionContext({
|
||||
actions: {
|
||||
deleteMessage: vi.fn(),
|
||||
regenerateMessage: vi.fn()
|
||||
@@ -641,7 +641,7 @@ describe('messageMenuBarActions', () => {
|
||||
const translateMessage = vi.fn()
|
||||
const language = { langCode: 'fr', label: 'French' } as any
|
||||
const translationItems = resolveMessageMenuBarTranslationItems(
|
||||
createContext({
|
||||
createActionContext({
|
||||
actions: { translateMessage } as MessageListActions,
|
||||
translateLanguages: [language],
|
||||
getTranslationLanguageLabel: () => 'French'
|
||||
@@ -663,7 +663,7 @@ describe('messageMenuBarActions', () => {
|
||||
|
||||
it('keeps copy-translation item available without translate capability', () => {
|
||||
const translationItems = resolveMessageMenuBarTranslationItems(
|
||||
createContext({
|
||||
createActionContext({
|
||||
hasTranslationBlocks: true,
|
||||
messageParts: [{ type: 'data-translation', data: { content: 'translated text' } }] as any
|
||||
})
|
||||
@@ -676,7 +676,7 @@ describe('messageMenuBarActions', () => {
|
||||
const removeMessageTranslation = vi.fn()
|
||||
const notifySuccess = vi.fn()
|
||||
const translationItems = resolveMessageMenuBarTranslationItems(
|
||||
createContext({
|
||||
createActionContext({
|
||||
hasTranslationBlocks: true,
|
||||
messageParts: [{ type: 'data-translation', data: { content: 'translated text' } }] as any,
|
||||
actions: { copyText: vi.fn(), removeMessageTranslation, notifySuccess } as MessageListActions
|
||||
@@ -698,7 +698,7 @@ describe('messageMenuBarActions', () => {
|
||||
|
||||
it('enables the translate toolbar action as abort while translation is running', () => {
|
||||
const toolbarActions = resolveMessageMenuBarToolbarActions(
|
||||
createContext({
|
||||
createActionContext({
|
||||
actions: { abortMessageTranslation: vi.fn() } as MessageListActions,
|
||||
isTranslating: true
|
||||
})
|
||||
@@ -710,7 +710,7 @@ describe('messageMenuBarActions', () => {
|
||||
it('routes copy through the injected clipboard action', async () => {
|
||||
const copyText = vi.fn()
|
||||
const setCopied = vi.fn()
|
||||
const context = createContext({
|
||||
const context = createActionContext({
|
||||
actions: { copyText } as MessageListActions,
|
||||
setCopied
|
||||
})
|
||||
@@ -725,7 +725,7 @@ describe('messageMenuBarActions', () => {
|
||||
const copyText = vi.fn()
|
||||
const copyRichContent = vi.fn()
|
||||
const setCopied = vi.fn()
|
||||
const context = createContext({
|
||||
const context = createActionContext({
|
||||
actions: { copyText, copyRichContent } as unknown as MessageListActions,
|
||||
message: {
|
||||
id: 'message-1',
|
||||
@@ -782,7 +782,7 @@ describe('messageMenuBarActions', () => {
|
||||
const copyText = vi.fn().mockRejectedValue(new Error('clipboard denied'))
|
||||
const notifyError = vi.fn()
|
||||
const setCopied = vi.fn()
|
||||
const context = createContext({
|
||||
const context = createActionContext({
|
||||
actions: { copyText, notifyError } as MessageListActions,
|
||||
setCopied
|
||||
})
|
||||
|
||||
@@ -459,6 +459,7 @@ export function CommandContextMenu({
|
||||
}
|
||||
const requestId = extraItemsRequestIdRef.current + 1
|
||||
extraItemsRequestIdRef.current = requestId
|
||||
onOpenChange?.(true)
|
||||
|
||||
let nativeExtraItems: MaybePromise<readonly CommandContextMenuExtraItem[]>
|
||||
try {
|
||||
@@ -507,8 +508,13 @@ export function CommandContextMenu({
|
||||
.catch((error) => {
|
||||
logger.error('Failed to show native command menu', error as Error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (extraItemsRequestIdRef.current === requestId) {
|
||||
onOpenChange?.(false)
|
||||
}
|
||||
})
|
||||
},
|
||||
[commandItems, location, mode, resolveExtraItemShortcutLabels, resolveExtraItems, runtime]
|
||||
[commandItems, location, mode, onOpenChange, resolveExtraItemShortcutLabels, resolveExtraItems, runtime]
|
||||
)
|
||||
|
||||
if (disabled || (!combinedItems.length && !hasLazyExtraItems)) {
|
||||
|
||||
@@ -181,12 +181,14 @@ function RegisteredTopicCreate({ onExecute }: { onExecute: () => void }) {
|
||||
function renderMenu({
|
||||
extraItems = [],
|
||||
onExecute = vi.fn(),
|
||||
onOpenChange,
|
||||
getExtraItems,
|
||||
pendingExtraItems,
|
||||
location = 'chat.input.tools.context'
|
||||
}: {
|
||||
extraItems?: readonly CommandContextMenuExtraItem[]
|
||||
onExecute?: () => void
|
||||
onOpenChange?: (open: boolean) => void
|
||||
getExtraItems?: (
|
||||
event: ReactMouseEvent
|
||||
) => readonly CommandContextMenuExtraItem[] | PromiseLike<readonly CommandContextMenuExtraItem[]>
|
||||
@@ -201,6 +203,7 @@ function renderMenu({
|
||||
location={location}
|
||||
extraItems={extraItems}
|
||||
pendingExtraItems={pendingExtraItems}
|
||||
onOpenChange={onOpenChange}
|
||||
getExtraItems={getExtraItems}>
|
||||
<button type="button">trigger</button>
|
||||
</CommandContextMenu>
|
||||
@@ -293,6 +296,25 @@ describe('CommandContextMenu', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('triggers onOpenChange around native context menus', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
showNativePopupMenuMock.mockResolvedValueOnce(null)
|
||||
|
||||
renderMenu({
|
||||
location: 'webcontents.context',
|
||||
onOpenChange,
|
||||
extraItems: [{ type: 'item', id: 'tool:branch', label: 'Branch', onSelect: vi.fn() }]
|
||||
})
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'trigger' }))
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNativePopupMenuMock).toHaveBeenCalled()
|
||||
expect(onOpenChange).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('uses event-time extra items for native menus', async () => {
|
||||
const onSelect = vi.fn()
|
||||
showNativePopupMenuMock.mockResolvedValueOnce({ type: 'custom', id: 'tool:fresh' })
|
||||
|
||||
@@ -30,10 +30,18 @@ function withLocalizedRouteTitle(tab: Tab): Tab {
|
||||
if (isPageTitledRoute(tab.url)) {
|
||||
return tab.title ? tab : { ...tab, title: getDefaultRouteTitle(tab.url) }
|
||||
}
|
||||
if (tab.id === 'home') return { ...tab, title: getDefaultRouteTitle(tab.url) }
|
||||
// Only auto-localize titles for top-level and settings routes. Parameterized
|
||||
// routes (e.g. /app/mini-app/<id>) preserve the title supplied at openTab
|
||||
// time so callers can pass per-entity names like a mini-app's display name.
|
||||
//
|
||||
// The `home` tab follows the SAME rule — it must not be special-cased into an
|
||||
// unconditional route-default title. When the home tab is reused for a
|
||||
// per-entity route (e.g. opening a mini-app from the sidebar), forcing the
|
||||
// route default here clobbers the caller-supplied title every render and
|
||||
// fights MiniAppPage's title-sync effect, spinning into an infinite
|
||||
// `updateTab` loop ("Maximum update depth exceeded"). On top-level / settings
|
||||
// routes the branch below still relocalizes the home tab, so language changes
|
||||
// are unaffected.
|
||||
if (!isTopLevelRoute(tab.url) && !isSettingsRouteTab(tab)) return tab
|
||||
return { ...tab, title: getDefaultRouteTitle(tab.url) }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
Library,
|
||||
MessageCircle,
|
||||
MousePointerClick,
|
||||
NotepadText
|
||||
NotepadText,
|
||||
Rocket
|
||||
} from 'lucide-react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -40,7 +41,8 @@ describe('getTabIcon', () => {
|
||||
['/app/notes', NotepadText],
|
||||
['/app/openclaw', OpenClawSidebarIcon],
|
||||
['/app/library', Library],
|
||||
['/app/mini-app', LayoutGrid]
|
||||
['/app/mini-app', LayoutGrid],
|
||||
['/app/launchpad', Rocket]
|
||||
])('returns the shared app icon for %s', (url, Icon) => {
|
||||
expect(getTabIcon(routeTab(url))).toBe(Icon)
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
MousePointerClick,
|
||||
NotepadText,
|
||||
Palette,
|
||||
Rocket,
|
||||
Settings
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -26,6 +27,7 @@ export const ROUTE_ICONS: Record<string, IconComponent> = {
|
||||
'/app/paintings': Palette,
|
||||
'/app/translate': Languages,
|
||||
'/app/mini-app': LayoutGrid,
|
||||
'/app/launchpad': Rocket,
|
||||
'/app/knowledge': FileSearch,
|
||||
'/app/library': Library,
|
||||
'/app/files': Folder,
|
||||
|
||||
@@ -4,13 +4,13 @@ import {
|
||||
} from '@renderer/components/chat/resources/resourceListRevealEvents'
|
||||
import { useWindowFrame } from '@renderer/components/chat/shell/WindowFrameContext'
|
||||
import { type TabsContextValue, useOptionalTabsContext } from '@renderer/hooks/tab'
|
||||
import type { SidebarAppId } from '@renderer/utils/sidebar'
|
||||
import {
|
||||
buildSidebarAppOpenMetadata,
|
||||
getSidebarApp,
|
||||
getSidebarAppTabInstanceKey,
|
||||
tabBelongsToApp
|
||||
} from '@renderer/utils/sidebar'
|
||||
import type { SidebarFavorite } from '@shared/data/preference/preferenceTypes'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { useMemo } from 'react'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
@@ -40,13 +40,13 @@ export interface ConversationNavigation {
|
||||
}
|
||||
|
||||
// Only conversation apps that own a resource sidebar emit a reveal on focus/open.
|
||||
function resolveRevealSource(appId: SidebarFavorite): ResourceListRevealSource | null {
|
||||
function resolveRevealSource(appId: SidebarAppId): ResourceListRevealSource | null {
|
||||
return appId === 'assistants' || appId === 'agents' ? appId : null
|
||||
}
|
||||
|
||||
function findConversationTabId(
|
||||
tabs: TabsContextValue | null,
|
||||
appId: SidebarFavorite,
|
||||
appId: SidebarAppId,
|
||||
key: string,
|
||||
excludeTabId?: string
|
||||
): string | undefined {
|
||||
@@ -63,7 +63,7 @@ function findConversationTabId(
|
||||
|
||||
function focusConversationTabImpl(
|
||||
tabs: TabsContextValue | null,
|
||||
appId: SidebarFavorite,
|
||||
appId: SidebarAppId,
|
||||
key: string,
|
||||
excludeTabId?: string
|
||||
): boolean {
|
||||
@@ -77,7 +77,7 @@ function focusConversationTabImpl(
|
||||
|
||||
function openConversationTabImpl(
|
||||
tabs: TabsContextValue | null,
|
||||
appId: SidebarFavorite,
|
||||
appId: SidebarAppId,
|
||||
key: string,
|
||||
title?: string,
|
||||
forceNew?: boolean
|
||||
@@ -92,7 +92,7 @@ function openConversationTabImpl(
|
||||
return openedId
|
||||
}
|
||||
|
||||
function openConversationWindowImpl(appId: SidebarFavorite, key: string, title?: string): void {
|
||||
function openConversationWindowImpl(appId: SidebarAppId, key: string, title?: string): void {
|
||||
const app = getSidebarApp(appId)
|
||||
if (!app?.instanceKey) return
|
||||
const metadata = buildSidebarAppOpenMetadata(app, key)
|
||||
@@ -116,7 +116,7 @@ function openConversationWindowImpl(appId: SidebarFavorite, key: string, title?:
|
||||
* Degrades to no-ops when there is no TabsProvider (tests, detached popups) or when the
|
||||
* app has no `instanceKey`.
|
||||
*/
|
||||
export function useConversationNavigation(appId: SidebarFavorite): ConversationNavigation {
|
||||
export function useConversationNavigation(appId: SidebarAppId): ConversationNavigation {
|
||||
const tabs = useOptionalTabsContext()
|
||||
const isDetachedWindowFrame = useWindowFrame().mode === 'window'
|
||||
|
||||
|
||||
31
src/renderer/hooks/useLaunchpadAppOrder.ts
Normal file
31
src/renderer/hooks/useLaunchpadAppOrder.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { usePreference } from '@data/hooks/usePreference'
|
||||
import { getOrderedLaunchpadApps, reorderLaunchpadApps } from '@renderer/utils/sidebar'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Single entry point for the `ui.launchpad.app_order` preference — the launchpad's
|
||||
* own built-in app tile order, independent of the sidebar favorites order.
|
||||
*
|
||||
* `orderedAppIds` is the normalized app order (stored order first, any missing app
|
||||
* appended in canonical order); `reorderApps` persists a new order. Mini app tiles
|
||||
* are ordered separately by their global `orderKey`, so the launchpad never touches
|
||||
* `ui.sidebar.favorites`.
|
||||
*/
|
||||
export function useLaunchpadAppOrder() {
|
||||
const { t } = useTranslation()
|
||||
const [appOrder, setAppOrder] = usePreference('ui.launchpad.app_order')
|
||||
|
||||
const orderedAppIds = useMemo(() => getOrderedLaunchpadApps(appOrder), [appOrder])
|
||||
|
||||
const reorderApps = useCallback(
|
||||
(orderedIds: readonly string[]) => {
|
||||
void setAppOrder(reorderLaunchpadApps(appOrder, orderedIds)).catch(() => {
|
||||
window.toast?.error(t('common.error'))
|
||||
})
|
||||
},
|
||||
[appOrder, setAppOrder, t]
|
||||
)
|
||||
|
||||
return { orderedAppIds, reorderApps }
|
||||
}
|
||||
66
src/renderer/hooks/useSidebarFavorites.ts
Normal file
66
src/renderer/hooks/useSidebarFavorites.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { usePreference } from '@data/hooks/usePreference'
|
||||
import type { SidebarAppId } from '@renderer/utils/sidebar'
|
||||
import {
|
||||
getOrderedVisibleSidebarFavoriteItems,
|
||||
getOrderedVisibleSidebarFavorites,
|
||||
getSidebarMiniAppFavoriteIds,
|
||||
removeSidebarMiniApp,
|
||||
reorderSidebarFavorites,
|
||||
setSidebarAppPinned,
|
||||
toggleSidebarMiniApp
|
||||
} from '@renderer/utils/sidebar'
|
||||
import type { SidebarFavoriteItem } from '@shared/data/preference/preferenceTypes'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Single entry point for the `ui.sidebar.favorites` preference.
|
||||
*
|
||||
* `favorites` is the full ordered mixed list (apps and mini apps interleaved) the
|
||||
* sidebar renders and drag-reorders as one list; `reorderFavorites` persists a new
|
||||
* mixed order. The partitioned `appFavorites` / `miniAppFavoriteIds` remain for
|
||||
* surfaces (launchpad, mini app menu) that need to know a single type's membership
|
||||
* (e.g. pin state), and `setAppPinned` / `toggleMiniApp` / `removeMiniApp` mutate
|
||||
* membership. The launchpad owns its own tile ordering elsewhere (built-in apps via
|
||||
* `ui.launchpad.app_order`, mini apps via `orderKey`), so favorites carries the
|
||||
* sidebar order only. Every mutation goes through the mix-preserving helpers in
|
||||
* `utils/sidebar`, so components never touch the raw `type` tags.
|
||||
*/
|
||||
export function useSidebarFavorites() {
|
||||
const { t } = useTranslation()
|
||||
const [favorites, setFavorites] = usePreference('ui.sidebar.favorites')
|
||||
|
||||
const favoriteItems = useMemo(() => getOrderedVisibleSidebarFavoriteItems(favorites), [favorites])
|
||||
const appFavorites = useMemo(() => getOrderedVisibleSidebarFavorites(favorites), [favorites])
|
||||
const miniAppFavoriteIds = useMemo(() => getSidebarMiniAppFavoriteIds(favorites), [favorites])
|
||||
|
||||
const persist = useCallback(
|
||||
(next: SidebarFavoriteItem[]) => {
|
||||
void setFavorites(next).catch(() => {
|
||||
window.toast?.error(t('common.error'))
|
||||
})
|
||||
},
|
||||
[setFavorites, t]
|
||||
)
|
||||
|
||||
const setAppPinned = useCallback(
|
||||
(id: SidebarAppId, pinned: boolean) => persist(setSidebarAppPinned(favorites, id, pinned)),
|
||||
[favorites, persist]
|
||||
)
|
||||
const toggleMiniApp = useCallback((id: string) => persist(toggleSidebarMiniApp(favorites, id)), [favorites, persist])
|
||||
const removeMiniApp = useCallback((id: string) => persist(removeSidebarMiniApp(favorites, id)), [favorites, persist])
|
||||
const reorderFavorites = useCallback(
|
||||
(orderedItems: readonly SidebarFavoriteItem[]) => persist(reorderSidebarFavorites(favorites, orderedItems)),
|
||||
[favorites, persist]
|
||||
)
|
||||
|
||||
return {
|
||||
favorites: favoriteItems,
|
||||
appFavorites,
|
||||
miniAppFavoriteIds,
|
||||
setAppPinned,
|
||||
reorderFavorites,
|
||||
toggleMiniApp,
|
||||
removeMiniApp
|
||||
}
|
||||
}
|
||||
@@ -2929,8 +2929,8 @@
|
||||
"apps": "Apps",
|
||||
"minapps": "Minapps",
|
||||
"miniApps": "MiniApps",
|
||||
"pin_to_sidebar": "Pin to sidebar",
|
||||
"unpin_from_sidebar": "Unpin from sidebar"
|
||||
"pin_to_sidebar": "Add to Sidebar",
|
||||
"unpin_from_sidebar": "Remove from Sidebar"
|
||||
},
|
||||
"library": {
|
||||
"action": {
|
||||
|
||||
@@ -2929,8 +2929,8 @@
|
||||
"apps": "应用",
|
||||
"minapps": "小程序",
|
||||
"miniApps": "小程序",
|
||||
"pin_to_sidebar": "固定到侧边栏",
|
||||
"unpin_from_sidebar": "取消固定"
|
||||
"pin_to_sidebar": "添加到侧边栏",
|
||||
"unpin_from_sidebar": "从侧边栏移除"
|
||||
},
|
||||
"library": {
|
||||
"action": {
|
||||
|
||||
@@ -2929,8 +2929,8 @@
|
||||
"apps": "應用",
|
||||
"minapps": "小程式",
|
||||
"miniApps": "小程式",
|
||||
"pin_to_sidebar": "固定到側邊欄",
|
||||
"unpin_from_sidebar": "取消固定"
|
||||
"pin_to_sidebar": "新增到側邊欄",
|
||||
"unpin_from_sidebar": "從側邊欄移除"
|
||||
},
|
||||
"library": {
|
||||
"action": {
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
import { Sortable } from '@cherrystudio/ui'
|
||||
import { usePreference } from '@data/hooks/usePreference'
|
||||
import { arrayMove } from '@dnd-kit/sortable'
|
||||
import { SIDEBAR_ICON_COMPONENTS } from '@renderer/components/app/sidebarIcons'
|
||||
import { CommandContextMenu, type CommandContextMenuExtraItem } from '@renderer/components/command'
|
||||
import App from '@renderer/components/MiniApp/MiniApp'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useLaunchpadAppOrder } from '@renderer/hooks/useLaunchpadAppOrder'
|
||||
import { useMiniApps } from '@renderer/hooks/useMiniApps'
|
||||
import { useSidebarFavorites } from '@renderer/hooks/useSidebarFavorites'
|
||||
import { getSidebarIconLabelKey } from '@renderer/i18n/label'
|
||||
import {
|
||||
getRequiredSidebarFavoritesVisible,
|
||||
getSidebarMenuPath,
|
||||
REQUIRED_SIDEBAR_FAVORITES,
|
||||
sanitizeSidebarFavorites,
|
||||
SIDEBAR_FAVORITE_ORDER
|
||||
} from '@renderer/utils/sidebar'
|
||||
import type { SidebarFavorite } from '@shared/data/preference/preferenceTypes'
|
||||
import type { SidebarAppId } from '@renderer/utils/sidebar'
|
||||
import { getSidebarMenuPath, REQUIRED_SIDEBAR_FAVORITES } from '@renderer/utils/sidebar'
|
||||
import type { MiniApp as MiniAppType } from '@shared/data/types/miniApp'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const BASE_URL = 'https://www.cherry-ai.com/'
|
||||
|
||||
const REQUIRED_SIDEBAR_FAVORITE_SET = new Set<SidebarFavorite>(REQUIRED_SIDEBAR_FAVORITES)
|
||||
const REQUIRED_SIDEBAR_FAVORITE_SET = new Set<SidebarAppId>(REQUIRED_SIDEBAR_FAVORITES)
|
||||
const LAUNCHPAD_GRID_CLASS = 'grid grid-cols-6 justify-items-center gap-2 px-2'
|
||||
const LAUNCHPAD_ITEM_CLASS = 'mx-auto w-[92px]'
|
||||
const SORTABLE_CONTENTS_STYLE = { display: 'contents' } as const
|
||||
|
||||
const APP_ICON_BACKGROUNDS: Record<SidebarFavorite, string> = {
|
||||
const APP_ICON_BACKGROUNDS: Record<SidebarAppId, string> = {
|
||||
assistants: 'linear-gradient(135deg, #111827, #4B5563)',
|
||||
agents: 'linear-gradient(135deg, #2563EB, #38BDF8)',
|
||||
store: 'linear-gradient(135deg, #0EA5E9, #6366F1)',
|
||||
@@ -36,46 +37,33 @@ const APP_ICON_BACKGROUNDS: Record<SidebarFavorite, string> = {
|
||||
openclaw: 'linear-gradient(135deg, #EF4444, #B91C1C)'
|
||||
}
|
||||
|
||||
function insertSidebarFavoriteByCanonicalOrder(favorites: SidebarFavorite[], favorite: SidebarFavorite) {
|
||||
const favoriteOrder = SIDEBAR_FAVORITE_ORDER.indexOf(favorite)
|
||||
const insertIndex = favorites.findIndex((existing) => SIDEBAR_FAVORITE_ORDER.indexOf(existing) > favoriteOrder)
|
||||
favorites.splice(insertIndex === -1 ? favorites.length : insertIndex, 0, favorite)
|
||||
}
|
||||
|
||||
function getSidebarFavoritesWithPinnedState({
|
||||
favorites,
|
||||
favorite,
|
||||
pinned
|
||||
}: {
|
||||
favorites: readonly SidebarFavorite[] | undefined
|
||||
favorite: SidebarFavorite
|
||||
pinned: boolean
|
||||
}): SidebarFavorite[] {
|
||||
const nextFavorites = sanitizeSidebarFavorites(favorites).filter((existing) => existing !== favorite)
|
||||
|
||||
for (const requiredFavorite of REQUIRED_SIDEBAR_FAVORITES) {
|
||||
if (!nextFavorites.includes(requiredFavorite)) {
|
||||
insertSidebarFavoriteByCanonicalOrder(nextFavorites, requiredFavorite)
|
||||
}
|
||||
}
|
||||
|
||||
if (pinned && !nextFavorites.includes(favorite)) {
|
||||
nextFavorites.push(favorite)
|
||||
}
|
||||
|
||||
return nextFavorites
|
||||
}
|
||||
|
||||
export default function LaunchpadPage() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [defaultPaintingProvider] = usePreference('feature.paintings.default_provider')
|
||||
const { pinned, openedKeepAliveMiniApps } = useMiniApps()
|
||||
const [sidebarFavorites, setSidebarFavorites] = usePreference('ui.sidebar.favorites')
|
||||
const { pinned, reorderMiniAppsByStatus } = useMiniApps()
|
||||
const { appFavorites, setAppPinned } = useSidebarFavorites()
|
||||
const { orderedAppIds, reorderApps } = useLaunchpadAppOrder()
|
||||
const suppressClickUntilRef = useRef(0)
|
||||
const draggedItemIdRef = useRef<string | null>(null)
|
||||
|
||||
const visibleSidebarFavoriteSet = useMemo(
|
||||
() => new Set(getRequiredSidebarFavoritesVisible(sidebarFavorites)),
|
||||
[sidebarFavorites]
|
||||
const visibleSidebarFavoriteSet = useMemo(() => new Set(appFavorites), [appFavorites])
|
||||
|
||||
const handleSortableDragStart = useCallback((event: { active: { id: string | number } }) => {
|
||||
draggedItemIdRef.current = String(event.active.id)
|
||||
suppressClickUntilRef.current = Date.now() + 500
|
||||
}, [])
|
||||
|
||||
// The pointer sensor fires a synthetic click on the dragged element after drop;
|
||||
// refresh the window on settle so the click is still suppressed after long drags.
|
||||
const handleSortableDragSettled = useCallback(() => {
|
||||
suppressClickUntilRef.current = Date.now() + 500
|
||||
}, [])
|
||||
|
||||
// Only swallow the post-drag click on the item that was actually dragged.
|
||||
const shouldSuppressLaunchClick = useCallback(
|
||||
(id: string) => id === draggedItemIdRef.current && Date.now() < suppressClickUntilRef.current,
|
||||
[]
|
||||
)
|
||||
|
||||
const navigateToUrl = useCallback(
|
||||
@@ -93,93 +81,146 @@ export default function LaunchpadPage() {
|
||||
[navigate]
|
||||
)
|
||||
|
||||
const openLaunchpadItem = (icon: SidebarFavorite) => {
|
||||
// Launchpad opens each app at its base entry (chat → new conversation,
|
||||
// agents → new session). Resuming the last-used instance is the sidebar's
|
||||
const openLaunchpadItem = (favorite: SidebarAppId) => {
|
||||
if (shouldSuppressLaunchClick(favorite)) return
|
||||
|
||||
// Launchpad opens each app at its base entry (chat -> new conversation,
|
||||
// agents -> new session). Resuming the last-used instance is the sidebar's
|
||||
// job, not the launcher's.
|
||||
const path = getSidebarMenuPath(icon, defaultPaintingProvider)
|
||||
const path = getSidebarMenuPath(favorite, defaultPaintingProvider)
|
||||
if (!path) return
|
||||
void navigateToUrl(path)
|
||||
}
|
||||
|
||||
const openMiniApp = (app: MiniAppType) => {
|
||||
if (shouldSuppressLaunchClick(app.appId)) return
|
||||
|
||||
void navigateToUrl(`/app/mini-app/${app.appId}`)
|
||||
}
|
||||
|
||||
const saveSidebarFavoritePinnedState = useCallback(
|
||||
(icon: SidebarFavorite, pinned: boolean) => {
|
||||
void setSidebarFavorites(
|
||||
getSidebarFavoritesWithPinnedState({
|
||||
favorites: sidebarFavorites,
|
||||
favorite: icon,
|
||||
pinned
|
||||
})
|
||||
).catch(() => {
|
||||
window.toast?.error(t('common.error'))
|
||||
})
|
||||
},
|
||||
[setSidebarFavorites, sidebarFavorites, t]
|
||||
)
|
||||
|
||||
const pinToSidebar = useCallback(
|
||||
(icon: SidebarFavorite) => {
|
||||
if (visibleSidebarFavoriteSet.has(icon)) return
|
||||
saveSidebarFavoritePinnedState(icon, true)
|
||||
(favorite: SidebarAppId) => {
|
||||
if (visibleSidebarFavoriteSet.has(favorite)) return
|
||||
setAppPinned(favorite, true)
|
||||
},
|
||||
[saveSidebarFavoritePinnedState, visibleSidebarFavoriteSet]
|
||||
[setAppPinned, visibleSidebarFavoriteSet]
|
||||
)
|
||||
|
||||
const unpinFromSidebar = useCallback(
|
||||
(icon: SidebarFavorite) => {
|
||||
if (!visibleSidebarFavoriteSet.has(icon) || REQUIRED_SIDEBAR_FAVORITE_SET.has(icon)) return
|
||||
saveSidebarFavoritePinnedState(icon, false)
|
||||
(favorite: SidebarAppId) => {
|
||||
if (!visibleSidebarFavoriteSet.has(favorite) || REQUIRED_SIDEBAR_FAVORITE_SET.has(favorite)) return
|
||||
setAppPinned(favorite, false)
|
||||
},
|
||||
[saveSidebarFavoritePinnedState, visibleSidebarFavoriteSet]
|
||||
[setAppPinned, visibleSidebarFavoriteSet]
|
||||
)
|
||||
|
||||
const getAppContextMenuItems = useCallback(
|
||||
(icon: SidebarFavorite): CommandContextMenuExtraItem[] => {
|
||||
const isPinned = visibleSidebarFavoriteSet.has(icon)
|
||||
(favorite: SidebarAppId): CommandContextMenuExtraItem[] => {
|
||||
const isPinned = visibleSidebarFavoriteSet.has(favorite)
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'item',
|
||||
id: `launchpad.${isPinned ? 'unpin-from-sidebar' : 'pin-to-sidebar'}.${icon}`,
|
||||
id: `launchpad.${isPinned ? 'unpin-from-sidebar' : 'pin-to-sidebar'}.${favorite}`,
|
||||
label: t(isPinned ? 'launchpad.unpin_from_sidebar' : 'launchpad.pin_to_sidebar'),
|
||||
enabled: !isPinned || !REQUIRED_SIDEBAR_FAVORITE_SET.has(icon),
|
||||
onSelect: () => (isPinned ? unpinFromSidebar(icon) : pinToSidebar(icon))
|
||||
enabled: !isPinned || !REQUIRED_SIDEBAR_FAVORITE_SET.has(favorite),
|
||||
onSelect: () => (isPinned ? unpinFromSidebar(favorite) : pinToSidebar(favorite))
|
||||
}
|
||||
]
|
||||
},
|
||||
[pinToSidebar, t, unpinFromSidebar, visibleSidebarFavoriteSet]
|
||||
)
|
||||
|
||||
const appMenuItems = SIDEBAR_FAVORITE_ORDER.flatMap((icon) => {
|
||||
const Icon = SIDEBAR_ICON_COMPONENTS[icon]
|
||||
if (!Icon || !getSidebarMenuPath(icon, defaultPaintingProvider)) return []
|
||||
// Built-in app tiles are ordered by the launchpad's own preference
|
||||
// (`ui.launchpad.app_order`), independent of the sidebar favorites order.
|
||||
// Every renderable app is drag-sortable in one grid.
|
||||
const appMenuItems = useMemo(
|
||||
() =>
|
||||
orderedAppIds.flatMap((favorite) => {
|
||||
const Icon = SIDEBAR_ICON_COMPONENTS[favorite]
|
||||
if (!Icon || !getSidebarMenuPath(favorite, defaultPaintingProvider)) return []
|
||||
|
||||
return [
|
||||
{
|
||||
id: icon,
|
||||
icon: <Icon size={32} />,
|
||||
text: t(getSidebarIconLabelKey(icon)),
|
||||
bgColor: APP_ICON_BACKGROUNDS[icon],
|
||||
menuItems: getAppContextMenuItems(icon)
|
||||
}
|
||||
]
|
||||
})
|
||||
return [
|
||||
{
|
||||
id: favorite,
|
||||
icon: <Icon size={32} />,
|
||||
text: t(getSidebarIconLabelKey(favorite)),
|
||||
bgColor: APP_ICON_BACKGROUNDS[favorite],
|
||||
menuItems: getAppContextMenuItems(favorite)
|
||||
}
|
||||
]
|
||||
}),
|
||||
[defaultPaintingProvider, getAppContextMenuItems, orderedAppIds, t]
|
||||
)
|
||||
|
||||
const sortedMiniApps = useMemo(() => {
|
||||
const result = [...pinned]
|
||||
// Mini app tiles are ordered by their global `orderKey` (shared with the mini
|
||||
// app settings page), independent of the sidebar favorites. Every pinned mini
|
||||
// app is drag-sortable in one grid; reordering persists purely to `orderKey`.
|
||||
const sortedMiniApps = useMemo(
|
||||
() => [...pinned].sort((a, b) => (a.orderKey < b.orderKey ? -1 : a.orderKey > b.orderKey ? 1 : 0)),
|
||||
[pinned]
|
||||
)
|
||||
|
||||
openedKeepAliveMiniApps.forEach((app) => {
|
||||
if (!result.some((pinnedApp) => pinnedApp.appId === app.appId)) {
|
||||
result.push(app)
|
||||
}
|
||||
})
|
||||
// Hold the drop result in local optimistic state so the Sortable keeps the tile
|
||||
// at its dropped slot while the async order-key write settles. Without this the
|
||||
// tile snaps back to its old position for one render — before the reordered
|
||||
// `/mini-apps` cache lands — and then jumps forward, a visible flashback. The
|
||||
// resync preserves the reference only when the refreshed list contains the same
|
||||
// objects in the same order; a rename/logo refresh with the same ids still adopts
|
||||
// the fresh objects.
|
||||
const [orderedMiniApps, setOrderedMiniApps] = useState(sortedMiniApps)
|
||||
useEffect(() => {
|
||||
setOrderedMiniApps((prev) => (sameMiniAppItems(prev, sortedMiniApps) ? prev : sortedMiniApps))
|
||||
}, [sortedMiniApps])
|
||||
|
||||
return result
|
||||
}, [openedKeepAliveMiniApps, pinned])
|
||||
const launchpadMiniAppsVisible = orderedMiniApps.length > 0
|
||||
|
||||
const handleAppsSortEnd = useCallback(
|
||||
({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) => {
|
||||
const nextItems = arrayMove(appMenuItems, oldIndex, newIndex)
|
||||
reorderApps(nextItems.map((item) => item.id))
|
||||
},
|
||||
[appMenuItems, reorderApps]
|
||||
)
|
||||
|
||||
const handleMiniAppsSortEnd = useCallback(
|
||||
({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) => {
|
||||
const nextItems = arrayMove(orderedMiniApps, oldIndex, newIndex)
|
||||
setOrderedMiniApps(nextItems)
|
||||
reorderMiniAppsByStatus('pinned', nextItems).catch(() => {
|
||||
window.toast?.error(t('miniApp.reorder_failed'))
|
||||
})
|
||||
},
|
||||
[orderedMiniApps, reorderMiniAppsByStatus, t]
|
||||
)
|
||||
|
||||
const renderAppMenuItem = (item: (typeof appMenuItems)[number]) => (
|
||||
<CommandContextMenu key={item.id} location="webcontents.context" extraItems={item.menuItems}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLaunchpadItem(item.id)}
|
||||
className={`${LAUNCHPAD_ITEM_CLASS} group flex cursor-pointer flex-col items-center gap-1 rounded-2xl px-1 py-2 text-center outline-none transition-transform duration-200 hover:scale-105 focus-visible:scale-105 active:scale-95`}>
|
||||
<span className="relative flex size-14 items-center justify-center">
|
||||
<span
|
||||
className="flex size-14 items-center justify-center rounded-2xl text-white shadow-sm [&_svg]:size-7 [&_svg]:text-white"
|
||||
style={{ background: item.bgColor }}>
|
||||
{item.icon}
|
||||
</span>
|
||||
</span>
|
||||
<span className="w-full overflow-hidden text-ellipsis whitespace-nowrap text-[12px] text-foreground">
|
||||
{item.text}
|
||||
</span>
|
||||
</button>
|
||||
</CommandContextMenu>
|
||||
)
|
||||
|
||||
const renderMiniAppItem = (app: MiniAppType) => (
|
||||
<div
|
||||
key={app.appId}
|
||||
className={`${LAUNCHPAD_ITEM_CLASS} flex justify-center rounded-[8px] px-0 py-2 transition-transform duration-200 hover:scale-105 active:scale-95`}>
|
||||
<App app={app} size={56} variant="launchpad" onOpen={openMiniApp} />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
@@ -189,42 +230,38 @@ export default function LaunchpadPage() {
|
||||
<h2 className="m-0 px-9 py-0 font-semibold text-[14px] text-foreground opacity-80">
|
||||
{t('launchpad.apps')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-6 gap-2 px-2">
|
||||
{appMenuItems.map((item) => (
|
||||
<CommandContextMenu key={item.id} location="webcontents.context" extraItems={item.menuItems}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLaunchpadItem(item.id)}
|
||||
className="group flex cursor-pointer flex-col items-center gap-1 rounded-2xl px-1 py-2 text-center outline-none transition-transform duration-200 hover:scale-105 focus-visible:scale-105 active:scale-95">
|
||||
<span className="relative flex size-14 items-center justify-center">
|
||||
<span
|
||||
className="flex size-14 items-center justify-center rounded-2xl text-white shadow-sm [&_svg]:size-7 [&_svg]:text-white"
|
||||
style={{ background: item.bgColor }}>
|
||||
{item.icon}
|
||||
</span>
|
||||
</span>
|
||||
<span className="w-full overflow-hidden text-ellipsis whitespace-nowrap text-[12px] text-foreground">
|
||||
{item.text}
|
||||
</span>
|
||||
</button>
|
||||
</CommandContextMenu>
|
||||
))}
|
||||
<div className={LAUNCHPAD_GRID_CLASS}>
|
||||
<Sortable
|
||||
items={appMenuItems}
|
||||
itemKey="id"
|
||||
layout="grid"
|
||||
listStyle={SORTABLE_CONTENTS_STYLE}
|
||||
onDragStart={handleSortableDragStart}
|
||||
onDragEnd={handleSortableDragSettled}
|
||||
onDragCancel={handleSortableDragSettled}
|
||||
onSortEnd={handleAppsSortEnd}
|
||||
renderItem={(item) => renderAppMenuItem(item)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{sortedMiniApps.length > 0 && (
|
||||
{launchpadMiniAppsVisible && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="m-0 px-9 py-0 font-semibold text-[14px] text-foreground opacity-80">
|
||||
{t('launchpad.miniApps')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-6 gap-2 px-2">
|
||||
{sortedMiniApps.map((app) => (
|
||||
<div
|
||||
key={app.appId}
|
||||
className="rounded-[8px] px-1 py-2 transition-transform duration-200 hover:scale-105 active:scale-95">
|
||||
<App app={app} size={56} variant="launchpad" onOpen={openMiniApp} />
|
||||
</div>
|
||||
))}
|
||||
<div className={LAUNCHPAD_GRID_CLASS}>
|
||||
<Sortable
|
||||
items={orderedMiniApps}
|
||||
itemKey="appId"
|
||||
layout="grid"
|
||||
listStyle={SORTABLE_CONTENTS_STYLE}
|
||||
onDragStart={handleSortableDragStart}
|
||||
onDragEnd={handleSortableDragSettled}
|
||||
onDragCancel={handleSortableDragSettled}
|
||||
onSortEnd={handleMiniAppsSortEnd}
|
||||
renderItem={(app) => renderMiniAppItem(app)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -233,3 +270,12 @@ export default function LaunchpadPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Same pinned mini app objects in the same order. */
|
||||
function sameMiniAppItems(a: MiniAppType[], b: MiniAppType[]): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import type { SidebarFavorite } from '@shared/data/preference/preferenceTypes'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import type { SidebarAppId } from '@renderer/utils/sidebar'
|
||||
import type { SidebarFavoriteItem } from '@shared/data/preference/preferenceTypes'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -11,13 +12,33 @@ const mocks = vi.hoisted(() => ({
|
||||
navigate: vi.fn(),
|
||||
pinnedMiniApps: [] as any[],
|
||||
openedMiniApps: [] as any[],
|
||||
reorderMiniAppsByStatus: vi.fn(() => Promise.resolve()),
|
||||
setSidebarFavorites: vi.fn(() => Promise.resolve()),
|
||||
sidebarFavorites: ['assistants'] as SidebarFavorite[]
|
||||
sidebarFavorites: [{ type: 'app', id: 'assistants' }] as SidebarFavoriteItem[],
|
||||
setAppOrder: vi.fn(() => Promise.resolve()),
|
||||
appOrder: [] as SidebarAppId[],
|
||||
sortableCalls: [] as any[]
|
||||
}))
|
||||
|
||||
vi.mock('@cherrystudio/ui', () => ({
|
||||
Sortable: ({ items, itemKey, renderItem, ...props }: any) => {
|
||||
mocks.sortableCalls.push({ items, itemKey, renderItem, ...props })
|
||||
const getKey = typeof itemKey === 'function' ? itemKey : (item: any) => item[itemKey]
|
||||
|
||||
return (
|
||||
<div data-testid={`sortable-${String(itemKey)}`}>
|
||||
{items.map((item: any) => (
|
||||
<div key={getKey(item)}>{renderItem(item, { dragging: false, overlay: false })}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@data/hooks/usePreference', () => ({
|
||||
usePreference: (key: string) => {
|
||||
if (key === 'feature.paintings.default_provider') return ['zhipu', vi.fn()]
|
||||
if (key === 'ui.launchpad.app_order') return [mocks.appOrder, mocks.setAppOrder]
|
||||
return [mocks.sidebarFavorites, mocks.setSidebarFavorites]
|
||||
}
|
||||
}))
|
||||
@@ -69,12 +90,13 @@ vi.mock('@renderer/components/Scrollbar', () => ({
|
||||
vi.mock('@renderer/hooks/useMiniApps', () => ({
|
||||
useMiniApps: () => ({
|
||||
openedKeepAliveMiniApps: mocks.openedMiniApps,
|
||||
pinned: mocks.pinnedMiniApps
|
||||
pinned: mocks.pinnedMiniApps,
|
||||
reorderMiniAppsByStatus: mocks.reorderMiniAppsByStatus
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/i18n/label', () => ({
|
||||
getSidebarIconLabelKey: (key: SidebarFavorite) =>
|
||||
getSidebarIconLabelKey: (key: SidebarAppId) =>
|
||||
({
|
||||
assistants: 'Chat',
|
||||
agents: 'Agent',
|
||||
@@ -107,8 +129,8 @@ vi.mock('react-i18next', () => ({
|
||||
'knowledge.title': 'Knowledge',
|
||||
'launchpad.apps': 'Apps',
|
||||
'launchpad.miniApps': 'Mini Apps',
|
||||
'launchpad.pin_to_sidebar': 'Pin to sidebar',
|
||||
'launchpad.unpin_from_sidebar': 'Unpin from sidebar',
|
||||
'launchpad.pin_to_sidebar': 'Add to Sidebar',
|
||||
'launchpad.unpin_from_sidebar': 'Remove from Sidebar',
|
||||
'miniApp.title': 'Mini Apps',
|
||||
'notes.title': 'Notes',
|
||||
'openclaw.title': 'OpenClaw',
|
||||
@@ -126,17 +148,25 @@ vi.mock('react-i18next', () => ({
|
||||
|
||||
import LaunchpadPage from '../LaunchpadPage'
|
||||
|
||||
const appFavorite = (id: SidebarAppId): SidebarFavoriteItem => ({ type: 'app', id })
|
||||
const miniAppFavorite = (id: string): SidebarFavoriteItem => ({ type: 'mini_app', id })
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
mocks.sortableCalls.length = 0
|
||||
})
|
||||
|
||||
describe('LaunchpadPage', () => {
|
||||
beforeEach(() => {
|
||||
mocks.pinnedMiniApps = []
|
||||
mocks.openedMiniApps = []
|
||||
mocks.sidebarFavorites = ['assistants']
|
||||
mocks.sidebarFavorites = [appFavorite('assistants')]
|
||||
mocks.appOrder = []
|
||||
mocks.sortableCalls.length = 0
|
||||
mocks.setSidebarFavorites.mockResolvedValue(undefined)
|
||||
mocks.setAppOrder.mockResolvedValue(undefined)
|
||||
mocks.reorderMiniAppsByStatus.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('renders the launchpad page chrome and app grid', () => {
|
||||
@@ -149,6 +179,91 @@ describe('LaunchpadPage', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Manage' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the launchpad grid at the original compact density', () => {
|
||||
mocks.pinnedMiniApps = [
|
||||
{
|
||||
appId: 'calculator',
|
||||
name: 'Calculator',
|
||||
logo: 'calc-logo',
|
||||
url: 'https://example.com',
|
||||
presetMiniAppId: 'calculator',
|
||||
status: 'pinned',
|
||||
orderKey: ''
|
||||
}
|
||||
]
|
||||
|
||||
render(<LaunchpadPage />)
|
||||
|
||||
const appsHeading = screen.getByRole('heading', { name: 'Apps' })
|
||||
const appsGrid = appsHeading.nextElementSibling
|
||||
const miniAppsGrid = screen.getByRole('heading', { name: 'Mini Apps' }).nextElementSibling
|
||||
const content = appsHeading.closest('section')?.parentElement
|
||||
|
||||
expect(content).toHaveClass('max-w-180', 'gap-5')
|
||||
expect(appsGrid).toHaveClass('grid-cols-6', 'justify-items-center', 'gap-2', 'px-2')
|
||||
expect(appsGrid).not.toHaveClass('gap-x-14', 'gap-y-8')
|
||||
expect(miniAppsGrid).toHaveClass('grid-cols-6', 'justify-items-center', 'gap-2', 'px-2')
|
||||
expect(screen.getByRole('button', { name: 'Chat' })).toHaveClass('mx-auto', 'w-[92px]')
|
||||
expect(screen.getByRole('button', { name: 'Calculator' }).parentElement).toHaveClass(
|
||||
'mx-auto',
|
||||
'w-[92px]',
|
||||
'justify-center'
|
||||
)
|
||||
})
|
||||
|
||||
it('orders app tiles by the launchpad app order, appending the rest canonically', () => {
|
||||
// Launchpad app order is independent of the sidebar favorites order.
|
||||
mocks.appOrder = ['translate', 'assistants', 'agents']
|
||||
mocks.sidebarFavorites = [appFavorite('assistants')]
|
||||
|
||||
render(<LaunchpadPage />)
|
||||
|
||||
const appLabels = screen
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.textContent)
|
||||
.filter((label): label is string =>
|
||||
[
|
||||
'Translate',
|
||||
'Chat',
|
||||
'Agent',
|
||||
'Paintings',
|
||||
'Library',
|
||||
'Mini Apps',
|
||||
'Knowledge',
|
||||
'Files',
|
||||
'Code',
|
||||
'Notes',
|
||||
'OpenClaw'
|
||||
].includes(label ?? '')
|
||||
)
|
||||
|
||||
expect(appLabels.slice(0, 4)).toEqual(['Translate', 'Chat', 'Agent', 'Paintings'])
|
||||
})
|
||||
|
||||
it('sorts every app tile and persists to the launchpad app order, not the sidebar favorites', () => {
|
||||
mocks.appOrder = ['translate', 'assistants', 'agents']
|
||||
|
||||
render(<LaunchpadPage />)
|
||||
|
||||
const systemSortable = mocks.sortableCalls.find((call) => call.itemKey === 'id')
|
||||
|
||||
// Every renderable app is in a single sortable (stored order first, canonical rest).
|
||||
expect(systemSortable.items.map((item: { id: string }) => item.id).slice(0, 3)).toEqual([
|
||||
'translate',
|
||||
'assistants',
|
||||
'agents'
|
||||
])
|
||||
|
||||
act(() => {
|
||||
systemSortable.onSortEnd({ oldIndex: 0, newIndex: 2 })
|
||||
})
|
||||
|
||||
const [persisted] = mocks.setAppOrder.mock.calls.at(-1) as unknown as [SidebarAppId[]]
|
||||
expect(persisted.slice(0, 3)).toEqual(['assistants', 'agents', 'translate'])
|
||||
expect(persisted).toHaveLength(systemSortable.items.length)
|
||||
expect(mocks.setSidebarFavorites).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('navigates apps inside the current launchpad tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -159,7 +274,22 @@ describe('LaunchpadPage', () => {
|
||||
expect(mocks.navigate).toHaveBeenCalledWith({ to: '/app/knowledge' })
|
||||
})
|
||||
|
||||
it('opens chat and agent apps fresh (new conversation/session) in the current tab', async () => {
|
||||
it('suppresses only the dragged launchpad item click', () => {
|
||||
render(<LaunchpadPage />)
|
||||
|
||||
const systemSortable = mocks.sortableCalls.find((call) => call.itemKey === 'id')
|
||||
act(() => {
|
||||
systemSortable.onDragStart({ active: { id: 'knowledge' } })
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Knowledge' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Chat' }))
|
||||
|
||||
expect(mocks.navigate).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.navigate).toHaveBeenCalledWith({ to: '/app/chat' })
|
||||
})
|
||||
|
||||
it('opens chat and agent apps fresh in the current tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<LaunchpadPage />)
|
||||
@@ -192,29 +322,300 @@ describe('LaunchpadPage', () => {
|
||||
expect(mocks.navigate).toHaveBeenCalledWith({ to: '/app/mini-app/calculator' })
|
||||
})
|
||||
|
||||
it('pins an app icon to the sidebar from the context menu', async () => {
|
||||
it('sorts every pinned mini app by order key and persists to order keys, not favorites', () => {
|
||||
const calculator = {
|
||||
appId: 'calculator',
|
||||
name: 'Calculator',
|
||||
logo: 'calc-logo',
|
||||
url: 'https://example.com',
|
||||
presetMiniAppId: 'calculator',
|
||||
status: 'pinned',
|
||||
orderKey: 'a'
|
||||
}
|
||||
const docs = {
|
||||
appId: 'docs',
|
||||
name: 'Docs',
|
||||
logo: 'docs-logo',
|
||||
url: 'https://docs.example.com',
|
||||
presetMiniAppId: 'docs',
|
||||
status: 'pinned',
|
||||
orderKey: 'b'
|
||||
}
|
||||
// Order-key order is 'a' < 'b', regardless of the array order passed in.
|
||||
mocks.pinnedMiniApps = [docs, calculator]
|
||||
mocks.sidebarFavorites = [appFavorite('assistants')]
|
||||
|
||||
render(<LaunchpadPage />)
|
||||
|
||||
const miniAppSortable = mocks.sortableCalls.find((call) => call.itemKey === 'appId')
|
||||
|
||||
expect(miniAppSortable.items.map((app: { appId: string }) => app.appId)).toEqual(['calculator', 'docs'])
|
||||
|
||||
act(() => {
|
||||
miniAppSortable.onSortEnd({ oldIndex: 0, newIndex: 1 })
|
||||
})
|
||||
|
||||
// The launchpad persists mini app order to the shared order key (independent of
|
||||
// the sidebar favorites), never writing `ui.sidebar.favorites`.
|
||||
expect(mocks.reorderMiniAppsByStatus).toHaveBeenCalledWith('pinned', [
|
||||
expect.objectContaining({ appId: 'docs' }),
|
||||
expect.objectContaining({ appId: 'calculator' })
|
||||
])
|
||||
expect(mocks.setSidebarFavorites).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('holds the dropped mini app order optimistically before the data refetches', () => {
|
||||
const calculator = {
|
||||
appId: 'calculator',
|
||||
name: 'Calculator',
|
||||
logo: 'calc-logo',
|
||||
url: 'https://example.com',
|
||||
presetMiniAppId: 'calculator',
|
||||
status: 'pinned',
|
||||
orderKey: 'a'
|
||||
}
|
||||
const docs = {
|
||||
appId: 'docs',
|
||||
name: 'Docs',
|
||||
logo: 'docs-logo',
|
||||
url: 'https://docs.example.com',
|
||||
presetMiniAppId: 'docs',
|
||||
status: 'pinned',
|
||||
orderKey: 'b'
|
||||
}
|
||||
mocks.pinnedMiniApps = [calculator, docs]
|
||||
|
||||
render(<LaunchpadPage />)
|
||||
|
||||
act(() => {
|
||||
const miniAppSortable = mocks.sortableCalls.find((call) => call.itemKey === 'appId')
|
||||
miniAppSortable.onSortEnd({ oldIndex: 0, newIndex: 1 })
|
||||
})
|
||||
|
||||
// Upstream `pinned` has NOT changed (no refetch yet); the sortable still shows
|
||||
// the dropped order from local optimistic state, so the tile never snaps back.
|
||||
const latestMiniAppSortable = mocks.sortableCalls.filter((call) => call.itemKey === 'appId').at(-1)
|
||||
expect(latestMiniAppSortable.items.map((app: { appId: string }) => app.appId)).toEqual(['docs', 'calculator'])
|
||||
})
|
||||
|
||||
it('replaces the optimistic mini app order when the refreshed pinned set changes', async () => {
|
||||
const calculator = {
|
||||
appId: 'calculator',
|
||||
name: 'Calculator',
|
||||
logo: 'calc-logo',
|
||||
url: 'https://example.com',
|
||||
presetMiniAppId: 'calculator',
|
||||
status: 'pinned',
|
||||
orderKey: 'a'
|
||||
}
|
||||
const docs = {
|
||||
appId: 'docs',
|
||||
name: 'Docs',
|
||||
logo: 'docs-logo',
|
||||
url: 'https://docs.example.com',
|
||||
presetMiniAppId: 'docs',
|
||||
status: 'pinned',
|
||||
orderKey: 'b'
|
||||
}
|
||||
const weather = {
|
||||
appId: 'weather',
|
||||
name: 'Weather',
|
||||
logo: 'weather-logo',
|
||||
url: 'https://weather.example.com',
|
||||
presetMiniAppId: 'weather',
|
||||
status: 'pinned',
|
||||
orderKey: 'c'
|
||||
}
|
||||
mocks.pinnedMiniApps = [calculator, docs]
|
||||
|
||||
const { rerender } = render(<LaunchpadPage />)
|
||||
|
||||
act(() => {
|
||||
const miniAppSortable = mocks.sortableCalls.find((call) => call.itemKey === 'appId')
|
||||
miniAppSortable.onSortEnd({ oldIndex: 0, newIndex: 1 })
|
||||
})
|
||||
|
||||
mocks.pinnedMiniApps = [docs, weather]
|
||||
rerender(<LaunchpadPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
const latestMiniAppSortable = mocks.sortableCalls.filter((call) => call.itemKey === 'appId').at(-1)
|
||||
expect(latestMiniAppSortable.items.map((app: { appId: string }) => app.appId)).toEqual(['docs', 'weather'])
|
||||
})
|
||||
expect(screen.queryByRole('button', { name: 'Calculator' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Weather' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('preserves the dropped mini app items reference when refresh returns the same objects in the same order', async () => {
|
||||
const calculator = {
|
||||
appId: 'calculator',
|
||||
name: 'Calculator',
|
||||
logo: 'calc-logo',
|
||||
url: 'https://example.com',
|
||||
presetMiniAppId: 'calculator',
|
||||
status: 'pinned',
|
||||
orderKey: 'a'
|
||||
}
|
||||
const docs = {
|
||||
appId: 'docs',
|
||||
name: 'Docs',
|
||||
logo: 'docs-logo',
|
||||
url: 'https://docs.example.com',
|
||||
presetMiniAppId: 'docs',
|
||||
status: 'pinned',
|
||||
orderKey: 'b'
|
||||
}
|
||||
mocks.pinnedMiniApps = [calculator, docs]
|
||||
|
||||
const { rerender } = render(<LaunchpadPage />)
|
||||
|
||||
act(() => {
|
||||
const miniAppSortable = mocks.sortableCalls.find((call) => call.itemKey === 'appId')
|
||||
miniAppSortable.onSortEnd({ oldIndex: 0, newIndex: 1 })
|
||||
})
|
||||
|
||||
const optimisticItems = mocks.sortableCalls.filter((call) => call.itemKey === 'appId').at(-1).items
|
||||
docs.orderKey = 'a'
|
||||
calculator.orderKey = 'b'
|
||||
mocks.pinnedMiniApps = [docs, calculator]
|
||||
rerender(<LaunchpadPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
const latestMiniAppSortable = mocks.sortableCalls.filter((call) => call.itemKey === 'appId').at(-1)
|
||||
expect(latestMiniAppSortable.items).toBe(optimisticItems)
|
||||
})
|
||||
})
|
||||
|
||||
it('adopts fresh mini app objects when the order is unchanged', async () => {
|
||||
const calculator = {
|
||||
appId: 'calculator',
|
||||
name: 'Calculator',
|
||||
logo: 'calc-logo',
|
||||
url: 'https://example.com',
|
||||
presetMiniAppId: 'calculator',
|
||||
status: 'pinned',
|
||||
orderKey: 'a'
|
||||
}
|
||||
mocks.pinnedMiniApps = [calculator]
|
||||
|
||||
const { rerender } = render(<LaunchpadPage />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Calculator' })).toBeInTheDocument()
|
||||
|
||||
mocks.pinnedMiniApps = [{ ...calculator, name: 'Calculator Pro' }]
|
||||
rerender(<LaunchpadPage />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Calculator Pro' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Calculator' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('makes every pinned mini app sortable regardless of sidebar favorites', () => {
|
||||
const calculator = {
|
||||
appId: 'calculator',
|
||||
name: 'Calculator',
|
||||
logo: 'calc-logo',
|
||||
url: 'https://example.com',
|
||||
presetMiniAppId: 'calculator',
|
||||
status: 'pinned',
|
||||
orderKey: 'a'
|
||||
}
|
||||
const docs = {
|
||||
appId: 'docs',
|
||||
name: 'Docs',
|
||||
logo: 'docs-logo',
|
||||
url: 'https://docs.example.com',
|
||||
presetMiniAppId: 'docs',
|
||||
status: 'pinned',
|
||||
orderKey: 'b'
|
||||
}
|
||||
// Only calculator is pinned to the sidebar; docs is launchpad-pinned only —
|
||||
// both are still sortable in the launchpad.
|
||||
mocks.pinnedMiniApps = [calculator, docs]
|
||||
mocks.sidebarFavorites = [appFavorite('assistants'), miniAppFavorite('calculator')]
|
||||
|
||||
render(<LaunchpadPage />)
|
||||
|
||||
const miniAppSortable = mocks.sortableCalls.find((call) => call.itemKey === 'appId')
|
||||
|
||||
expect(miniAppSortable.items.map((app: { appId: string }) => app.appId)).toEqual(['calculator', 'docs'])
|
||||
expect(screen.getByRole('button', { name: 'Calculator' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Docs' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only launchpad-pinned mini apps, excluding opened-but-unpinned ones', () => {
|
||||
const calculator = {
|
||||
appId: 'calculator',
|
||||
name: 'Calculator',
|
||||
logo: 'calc-logo',
|
||||
url: 'https://example.com',
|
||||
presetMiniAppId: 'calculator',
|
||||
status: 'pinned',
|
||||
orderKey: 'a'
|
||||
}
|
||||
const scratch = {
|
||||
appId: 'scratch',
|
||||
name: 'Scratch',
|
||||
logo: 'scratch-logo',
|
||||
url: 'https://scratch.example.com',
|
||||
presetMiniAppId: 'scratch',
|
||||
status: 'enabled',
|
||||
orderKey: 'b'
|
||||
}
|
||||
mocks.pinnedMiniApps = [calculator]
|
||||
// scratch is opened (e.g. via the sidebar) but not added to the launchpad —
|
||||
// launchpad membership must stay independent of what is merely opened.
|
||||
mocks.openedMiniApps = [calculator, scratch]
|
||||
|
||||
render(<LaunchpadPage />)
|
||||
|
||||
// Launchpad membership is driven by pinned status, not by what is merely opened.
|
||||
expect(screen.getByRole('button', { name: 'Calculator' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Scratch' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the mini apps section when only opened-but-unpinned apps exist', () => {
|
||||
const scratch = {
|
||||
appId: 'scratch',
|
||||
name: 'Scratch',
|
||||
logo: 'scratch-logo',
|
||||
url: 'https://scratch.example.com',
|
||||
presetMiniAppId: 'scratch',
|
||||
status: 'enabled',
|
||||
orderKey: 'b'
|
||||
}
|
||||
mocks.pinnedMiniApps = []
|
||||
mocks.openedMiniApps = [scratch]
|
||||
|
||||
render(<LaunchpadPage />)
|
||||
|
||||
expect(screen.queryByRole('heading', { name: 'Mini Apps' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Scratch' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds an app icon to the sidebar from the context menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<LaunchpadPage />)
|
||||
|
||||
expect(screen.getByTestId('menu-launchpad.unpin-from-sidebar.assistants')).toHaveTextContent('Unpin from sidebar')
|
||||
expect(screen.getByTestId('menu-launchpad.unpin-from-sidebar.assistants')).toHaveTextContent('Remove from Sidebar')
|
||||
expect(screen.getByTestId('menu-launchpad.unpin-from-sidebar.assistants')).toBeDisabled()
|
||||
expect(screen.getByTestId('menu-launchpad.pin-to-sidebar.knowledge')).toHaveTextContent('Add to Sidebar')
|
||||
|
||||
await user.click(screen.getByTestId('menu-launchpad.pin-to-sidebar.knowledge'))
|
||||
|
||||
expect(mocks.setSidebarFavorites).toHaveBeenCalledWith(['assistants', 'knowledge'])
|
||||
expect(mocks.setSidebarFavorites).toHaveBeenCalledWith([appFavorite('assistants'), appFavorite('knowledge')])
|
||||
})
|
||||
|
||||
it('unpins an existing sidebar app icon from the context menu', async () => {
|
||||
it('removes an existing sidebar app icon from the context menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.sidebarFavorites = ['assistants', 'knowledge']
|
||||
mocks.sidebarFavorites = [appFavorite('assistants'), appFavorite('knowledge')]
|
||||
|
||||
render(<LaunchpadPage />)
|
||||
|
||||
expect(screen.getByTestId('menu-launchpad.unpin-from-sidebar.knowledge')).toHaveTextContent('Unpin from sidebar')
|
||||
expect(screen.getByTestId('menu-launchpad.unpin-from-sidebar.knowledge')).toHaveTextContent('Remove from Sidebar')
|
||||
|
||||
await user.click(screen.getByTestId('menu-launchpad.unpin-from-sidebar.knowledge'))
|
||||
|
||||
expect(mocks.setSidebarFavorites).toHaveBeenCalledWith(['assistants'])
|
||||
expect(mocks.setSidebarFavorites).toHaveBeenCalledWith([appFavorite('assistants')])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { LogoAvatar } from '@renderer/components/Icons'
|
||||
import { getMiniAppsLogo } from '@renderer/components/Icons/miniAppsLogo'
|
||||
import { useCurrentTab, useCurrentTabId } from '@renderer/hooks/tab'
|
||||
import { useCurrentTab, useCurrentTabId, useIsActiveTab } from '@renderer/hooks/tab'
|
||||
import { useOptionalTabsContext } from '@renderer/hooks/tab'
|
||||
import { useMiniAppPopup } from '@renderer/hooks/useMiniAppPopup'
|
||||
import { useMiniApps } from '@renderer/hooks/useMiniApps'
|
||||
@@ -32,6 +32,7 @@ const MiniAppPage: FC = () => {
|
||||
const { appId } = useParams({ strict: false })
|
||||
const currentTabId = useCurrentTabId()
|
||||
const currentTab = useCurrentTab()
|
||||
const isActiveTab = useIsActiveTab()
|
||||
const tabsContext = useOptionalTabsContext()
|
||||
const updateTab = tabsContext?.updateTab
|
||||
const { openMiniAppKeepAlive } = useMiniAppPopup()
|
||||
@@ -63,16 +64,24 @@ const MiniAppPage: FC = () => {
|
||||
}, [app, currentTab, currentTabId, displayName, updateTab])
|
||||
|
||||
useEffect(() => {
|
||||
// Only the active tab drives the keep-alive pool. `openMiniAppKeepAlive`
|
||||
// mutates *global* state — `currentMiniAppId` and the LRU order of the
|
||||
// shared keep-alive list. Background mini-app pages stay mounted (React 19
|
||||
// Activity keep-alive), so without this guard two mounted pages — e.g. a
|
||||
// pinned mini-app tab plus the one just opened — would each keep claiming
|
||||
// `currentMiniAppId` and reordering themselves to the tail, ping-ponging the
|
||||
// shared state into an infinite render loop (Maximum update depth). Each app
|
||||
// still registers itself when it becomes active and, being kept alive, stays
|
||||
// in the pool afterward.
|
||||
if (!isActiveTab) return
|
||||
if (isLoading) return
|
||||
if (error) {
|
||||
logger.error('Failed to load mini apps', error instanceof Error ? error : new Error(String(error)))
|
||||
return
|
||||
}
|
||||
if (!app) return
|
||||
// Ensure the keep-alive pool picks up this app and currentMiniAppId stays
|
||||
// in sync with the route-changed appId.
|
||||
openMiniAppKeepAlive(app)
|
||||
}, [app, openMiniAppKeepAlive, isLoading, error])
|
||||
}, [isActiveTab, app, openMiniAppKeepAlive, isLoading, error])
|
||||
|
||||
// -------------- Tab Shell logic --------------
|
||||
// Hooks must be called before any return, so define them early with null-checks inside
|
||||
|
||||
@@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => ({
|
||||
openedKeepAliveMiniApps: [] as MiniApp[],
|
||||
openMiniAppKeepAlive: vi.fn(),
|
||||
updateTab: vi.fn(),
|
||||
isActiveTab: true,
|
||||
currentTab: {
|
||||
id: 'launchpad-tab',
|
||||
type: 'route',
|
||||
@@ -55,6 +56,7 @@ vi.mock('@renderer/pages/miniApps/components/WebviewSearch', () => ({
|
||||
vi.mock('@renderer/hooks/tab', () => ({
|
||||
useCurrentTab: () => mocks.currentTab,
|
||||
useCurrentTabId: () => mocks.currentTab.id,
|
||||
useIsActiveTab: () => mocks.isActiveTab,
|
||||
useOptionalTabsContext: () => ({
|
||||
tabs: [mocks.currentTab],
|
||||
updateTab: mocks.updateTab
|
||||
@@ -111,6 +113,7 @@ describe('MiniAppPage', () => {
|
||||
})
|
||||
]
|
||||
mocks.openedKeepAliveMiniApps = []
|
||||
mocks.isActiveTab = true
|
||||
mocks.currentTab = {
|
||||
id: 'launchpad-tab',
|
||||
type: 'route',
|
||||
@@ -139,4 +142,21 @@ describe('MiniAppPage', () => {
|
||||
)
|
||||
expect(mocks.openMiniAppKeepAlive).toHaveBeenCalledWith(mocks.allApps[0])
|
||||
})
|
||||
|
||||
it('does not drive the keep-alive pool from a background (non-active) tab', async () => {
|
||||
// A backgrounded mini-app page (e.g. a pinned mini-app tab still mounted via
|
||||
// keep-alive) must not touch the global currentMiniAppId / LRU order — that
|
||||
// is what ping-pongs two mounted pages into an infinite render loop.
|
||||
mocks.isActiveTab = false
|
||||
|
||||
render(<MiniAppPage />)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.updateTab).toHaveBeenCalledWith('launchpad-tab', {
|
||||
title: 'ChatGPT',
|
||||
icon: 'chat-logo'
|
||||
})
|
||||
)
|
||||
expect(mocks.openMiniAppKeepAlive).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { SIDEBAR_ICON_COMPONENTS } from '@renderer/components/app/sidebarIcons'
|
||||
import type { SidebarFavorite, SidebarFavoriteItem } from '@shared/data/preference/preferenceTypes'
|
||||
import { Library } from 'lucide-react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getOrderedLaunchpadApps,
|
||||
getOrderedVisibleSidebarFavoriteItems,
|
||||
getOrderedVisibleSidebarFavorites,
|
||||
getRequiredSidebarFavoritesVisible,
|
||||
getSidebarFavoriteItems,
|
||||
getSidebarMenuPath,
|
||||
getSidebarMiniAppFavoriteIds,
|
||||
removeSidebarMiniApp,
|
||||
reorderLaunchpadApps,
|
||||
reorderSidebarFavorites,
|
||||
resolveSidebarActiveItem,
|
||||
SIDEBAR_FAVORITE_ORDER
|
||||
setSidebarAppPinned,
|
||||
SIDEBAR_FAVORITE_ORDER,
|
||||
toggleSidebarMiniApp
|
||||
} from '../sidebar'
|
||||
|
||||
const appFavorite = (id: SidebarFavorite): SidebarFavoriteItem => ({ type: 'app', id })
|
||||
const miniAppFavorite = (id: string): SidebarFavoriteItem => ({ type: 'mini_app', id })
|
||||
|
||||
describe('sidebar config helpers', () => {
|
||||
it('keeps the fixed sidebar app order available', () => {
|
||||
expect(SIDEBAR_FAVORITE_ORDER.slice(0, 6)).toEqual([
|
||||
@@ -22,23 +34,98 @@ describe('sidebar config helpers', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('adds required sidebar favorites back in fixed order when reading visible preferences', () => {
|
||||
expect(getRequiredSidebarFavoritesVisible(['translate'])).toEqual(['assistants', 'translate'])
|
||||
})
|
||||
|
||||
it('preserves the preference order when reading ordered visible sidebar favorites', () => {
|
||||
expect(getOrderedVisibleSidebarFavorites(['translate', 'assistants', 'agents'])).toEqual([
|
||||
'translate',
|
||||
'assistants',
|
||||
'agents'
|
||||
])
|
||||
expect(
|
||||
getOrderedVisibleSidebarFavorites([appFavorite('translate'), appFavorite('assistants'), appFavorite('agents')])
|
||||
).toEqual(['translate', 'assistants', 'agents'])
|
||||
})
|
||||
|
||||
it('sanitizes ordered visible sidebar favorites and keeps required favorites visible', () => {
|
||||
expect(getOrderedVisibleSidebarFavorites(['translate', 'unknown' as never, 'translate', 'agents'])).toEqual([
|
||||
'assistants',
|
||||
'translate',
|
||||
'agents'
|
||||
expect(
|
||||
getOrderedVisibleSidebarFavorites([
|
||||
appFavorite('translate'),
|
||||
{ type: 'app', id: 'unknown' } as never,
|
||||
appFavorite('translate'),
|
||||
appFavorite('agents')
|
||||
])
|
||||
).toEqual(['assistants', 'translate', 'agents'])
|
||||
})
|
||||
|
||||
it('ignores mini app favorites when reading system sidebar favorites', () => {
|
||||
expect(
|
||||
getOrderedVisibleSidebarFavorites([
|
||||
appFavorite('translate'),
|
||||
miniAppFavorite('calculator'),
|
||||
appFavorite('assistants'),
|
||||
appFavorite('agents')
|
||||
])
|
||||
).toEqual(['translate', 'assistants', 'agents'])
|
||||
})
|
||||
|
||||
it('returns the full mixed list interleaved in stored order with required apps forced in', () => {
|
||||
expect(
|
||||
getOrderedVisibleSidebarFavoriteItems([
|
||||
appFavorite('translate'),
|
||||
miniAppFavorite('calculator'),
|
||||
appFavorite('agents')
|
||||
])
|
||||
).toEqual([
|
||||
appFavorite('assistants'),
|
||||
appFavorite('translate'),
|
||||
miniAppFavorite('calculator'),
|
||||
appFavorite('agents')
|
||||
])
|
||||
})
|
||||
|
||||
it('does not prepend a required app that is already present at any position', () => {
|
||||
expect(getOrderedVisibleSidebarFavoriteItems([miniAppFavorite('calculator'), appFavorite('assistants')])).toEqual([
|
||||
miniAppFavorite('calculator'),
|
||||
appFavorite('assistants')
|
||||
])
|
||||
})
|
||||
|
||||
it('reads mini app favorite ids from typed sidebar favorites', () => {
|
||||
expect(
|
||||
getSidebarMiniAppFavoriteIds([
|
||||
appFavorite('translate'),
|
||||
miniAppFavorite('calculator'),
|
||||
appFavorite('assistants'),
|
||||
miniAppFavorite('calculator'),
|
||||
miniAppFavorite('weather')
|
||||
])
|
||||
).toEqual(['calculator', 'weather'])
|
||||
})
|
||||
|
||||
it('dedupes favorites and drops unknown app favorites', () => {
|
||||
expect(
|
||||
getSidebarFavoriteItems([
|
||||
appFavorite('translate'),
|
||||
miniAppFavorite('calculator'),
|
||||
appFavorite('assistants'),
|
||||
miniAppFavorite('calculator'),
|
||||
{ type: 'app', id: 'unknown' } as never
|
||||
])
|
||||
).toEqual([appFavorite('translate'), miniAppFavorite('calculator'), appFavorite('assistants')])
|
||||
})
|
||||
|
||||
it('drops unknown favorite types from visible reads while keeping surrounding leaves', () => {
|
||||
const group = { type: 'group', id: 'g1', name: 'Group', items: [] } as unknown as SidebarFavoriteItem
|
||||
|
||||
expect(getSidebarFavoriteItems([appFavorite('translate'), group, miniAppFavorite('calculator')])).toEqual([
|
||||
appFavorite('translate'),
|
||||
miniAppFavorite('calculator')
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves extra per-item fields through normalization (non-lossy round-trip)', () => {
|
||||
// Future per-item params must survive the normalize round-trip instead of being
|
||||
// rebuilt away from just the id.
|
||||
const appWithExtra = { type: 'app', id: 'assistants', badge: 3 } as unknown as SidebarFavoriteItem
|
||||
const miniWithExtra = { type: 'mini_app', id: 'calculator', color: '#fff' } as unknown as SidebarFavoriteItem
|
||||
|
||||
expect(getSidebarFavoriteItems([appWithExtra, miniWithExtra])).toEqual([
|
||||
{ type: 'app', id: 'assistants', badge: 3 },
|
||||
{ type: 'mini_app', id: 'calculator', color: '#fff' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -55,4 +142,135 @@ describe('sidebar config helpers', () => {
|
||||
expect(resolveSidebarActiveItem('/app/chat?topicId=abc')).toBe('assistants')
|
||||
expect(resolveSidebarActiveItem('/app/agents?sessionId=xyz')).toBe('agents')
|
||||
})
|
||||
|
||||
it('does not mark the launchpad sidebar item active for concrete mini app routes', () => {
|
||||
expect(resolveSidebarActiveItem('/app/mini-app')).toBe('mini_app')
|
||||
expect(resolveSidebarActiveItem('/app/mini-app/qwen')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sidebar favorites mutations', () => {
|
||||
it('pins an app to the very end of the mixed list', () => {
|
||||
expect(setSidebarAppPinned([appFavorite('assistants'), miniAppFavorite('calculator')], 'knowledge', true)).toEqual([
|
||||
appFavorite('assistants'),
|
||||
miniAppFavorite('calculator'),
|
||||
appFavorite('knowledge')
|
||||
])
|
||||
})
|
||||
|
||||
it('unpins an app while preserving mini apps', () => {
|
||||
expect(
|
||||
setSidebarAppPinned(
|
||||
[appFavorite('assistants'), appFavorite('knowledge'), miniAppFavorite('calculator')],
|
||||
'knowledge',
|
||||
false
|
||||
)
|
||||
).toEqual([appFavorite('assistants'), miniAppFavorite('calculator')])
|
||||
})
|
||||
|
||||
it('never unpins a required app', () => {
|
||||
expect(setSidebarAppPinned([appFavorite('assistants'), appFavorite('knowledge')], 'assistants', false)).toEqual([
|
||||
appFavorite('assistants'),
|
||||
appFavorite('knowledge')
|
||||
])
|
||||
})
|
||||
|
||||
it('toggles a mini app on and off, preserving apps', () => {
|
||||
const added = toggleSidebarMiniApp([appFavorite('assistants'), miniAppFavorite('calculator')], 'weather')
|
||||
expect(added).toEqual([appFavorite('assistants'), miniAppFavorite('calculator'), miniAppFavorite('weather')])
|
||||
expect(toggleSidebarMiniApp(added, 'calculator')).toEqual([appFavorite('assistants'), miniAppFavorite('weather')])
|
||||
})
|
||||
|
||||
it('removes a mini app while preserving apps and other mini apps', () => {
|
||||
expect(
|
||||
removeSidebarMiniApp(
|
||||
[appFavorite('assistants'), miniAppFavorite('calculator'), miniAppFavorite('weather')],
|
||||
'calculator'
|
||||
)
|
||||
).toEqual([appFavorite('assistants'), miniAppFavorite('weather')])
|
||||
})
|
||||
|
||||
it('preserves forward-compatible unknown items when mutating favorites', () => {
|
||||
const group = {
|
||||
type: 'group',
|
||||
id: 'g1',
|
||||
name: 'Group',
|
||||
items: [miniAppFavorite('calculator')]
|
||||
} as unknown as SidebarFavoriteItem
|
||||
|
||||
expect(toggleSidebarMiniApp([appFavorite('assistants'), group], 'weather')).toEqual([
|
||||
appFavorite('assistants'),
|
||||
miniAppFavorite('weather'),
|
||||
group
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorderSidebarFavorites (mixed cross-type reorder)', () => {
|
||||
it('reorders apps and mini apps together into any interleaved order', () => {
|
||||
expect(
|
||||
reorderSidebarFavorites(
|
||||
[appFavorite('assistants'), appFavorite('knowledge'), miniAppFavorite('calculator')],
|
||||
[miniAppFavorite('calculator'), appFavorite('assistants'), appFavorite('knowledge')]
|
||||
)
|
||||
).toEqual([miniAppFavorite('calculator'), appFavorite('assistants'), appFavorite('knowledge')])
|
||||
})
|
||||
|
||||
it('keeps stored favorites missing from a partial order at the end', () => {
|
||||
expect(
|
||||
reorderSidebarFavorites(
|
||||
[appFavorite('assistants'), miniAppFavorite('calculator'), miniAppFavorite('stale')],
|
||||
[miniAppFavorite('calculator'), appFavorite('assistants')]
|
||||
)
|
||||
).toEqual([miniAppFavorite('calculator'), appFavorite('assistants'), miniAppFavorite('stale')])
|
||||
})
|
||||
|
||||
it('drops requested items that are not stored favorites', () => {
|
||||
expect(
|
||||
reorderSidebarFavorites(
|
||||
[appFavorite('assistants'), miniAppFavorite('calculator')],
|
||||
[miniAppFavorite('ghost'), miniAppFavorite('calculator'), appFavorite('assistants')]
|
||||
)
|
||||
).toEqual([miniAppFavorite('calculator'), appFavorite('assistants')])
|
||||
})
|
||||
|
||||
it('keeps a required app once when the requested reorder omits it', () => {
|
||||
const reordered = reorderSidebarFavorites([appFavorite('knowledge')], [appFavorite('knowledge')])
|
||||
|
||||
expect(reordered).toEqual([appFavorite('knowledge'), appFavorite('assistants')])
|
||||
expect(reordered.filter((item) => item.type === 'app' && item.id === 'assistants')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('launchpad app order (independent from sidebar favorites)', () => {
|
||||
it('falls back to the canonical order when the store is empty', () => {
|
||||
expect(getOrderedLaunchpadApps(undefined)).toEqual(SIDEBAR_FAVORITE_ORDER)
|
||||
expect(getOrderedLaunchpadApps([])).toEqual(SIDEBAR_FAVORITE_ORDER)
|
||||
})
|
||||
|
||||
it('keeps the stored order first and appends missing apps in canonical order', () => {
|
||||
const ordered = getOrderedLaunchpadApps(['files', 'assistants'])
|
||||
expect(ordered.slice(0, 2)).toEqual(['files', 'assistants'])
|
||||
expect([...ordered].sort()).toEqual([...SIDEBAR_FAVORITE_ORDER].sort())
|
||||
expect(new Set(ordered).size).toBe(ordered.length)
|
||||
})
|
||||
|
||||
it('drops unknown and duplicate stored ids', () => {
|
||||
const ordered = getOrderedLaunchpadApps(['files', 'ghost', 'files', 'assistants'])
|
||||
expect(ordered.slice(0, 2)).toEqual(['files', 'assistants'])
|
||||
expect(ordered).not.toContain('ghost')
|
||||
expect(new Set(ordered).size).toBe(ordered.length)
|
||||
})
|
||||
|
||||
it('reorders to the requested order and keeps missing apps at the end', () => {
|
||||
const next = reorderLaunchpadApps(['assistants', 'agents', 'files'], ['files', 'assistants', 'agents'])
|
||||
expect(next.slice(0, 3)).toEqual(['files', 'assistants', 'agents'])
|
||||
expect([...next].sort()).toEqual([...SIDEBAR_FAVORITE_ORDER].sort())
|
||||
})
|
||||
|
||||
it('drops unknown ids from a requested reorder', () => {
|
||||
const next = reorderLaunchpadApps(['assistants', 'agents'], ['ghost', 'agents', 'assistants'])
|
||||
expect(next.slice(0, 2)).toEqual(['agents', 'assistants'])
|
||||
expect(next).not.toContain('ghost')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
hasTabInstanceMetadataForApp
|
||||
} from '@renderer/utils/tabInstanceMetadata'
|
||||
import type { Tab } from '@shared/data/cache/cacheValueTypes'
|
||||
import type { SidebarFavorite } from '@shared/data/preference/preferenceTypes'
|
||||
import type { SidebarFavorite, SidebarFavoriteItem } from '@shared/data/preference/preferenceTypes'
|
||||
|
||||
/**
|
||||
* Context passed to sidebar navigation handlers. Carries per-call state the
|
||||
@@ -34,8 +34,8 @@ export interface SidebarInstanceKey {
|
||||
urlForKey: (key: string) => string
|
||||
}
|
||||
|
||||
export interface SidebarApp {
|
||||
id: SidebarFavorite
|
||||
interface SidebarAppDefinition<Id extends SidebarFavorite = SidebarFavorite> {
|
||||
id: Id
|
||||
routePrefix: string
|
||||
/** Url to open when no tab exists yet (defaults to `routePrefix`). */
|
||||
resolveUrl?: (ctx: SidebarNavContext) => string
|
||||
@@ -66,7 +66,7 @@ function isMessageOnlyConversationUrl(url: string): boolean {
|
||||
* Single source of truth for sidebar applications.
|
||||
* Order here is the canonical sidebar order and drives preference defaults.
|
||||
*/
|
||||
export const SIDEBAR_APPS: readonly SidebarApp[] = [
|
||||
const SIDEBAR_APP_DEFINITIONS = [
|
||||
{
|
||||
id: 'assistants',
|
||||
routePrefix: '/app/chat',
|
||||
@@ -123,17 +123,22 @@ export const SIDEBAR_APPS: readonly SidebarApp[] = [
|
||||
id: 'openclaw',
|
||||
routePrefix: '/app/openclaw'
|
||||
}
|
||||
]
|
||||
] as const satisfies readonly SidebarAppDefinition[]
|
||||
|
||||
const SIDEBAR_APP_BY_ID: Record<SidebarFavorite, SidebarApp> = SIDEBAR_APPS.reduce(
|
||||
export type SidebarAppId = (typeof SIDEBAR_APP_DEFINITIONS)[number]['id']
|
||||
export type SidebarApp = SidebarAppDefinition<SidebarAppId>
|
||||
|
||||
export const SIDEBAR_APPS: readonly SidebarApp[] = SIDEBAR_APP_DEFINITIONS
|
||||
|
||||
const SIDEBAR_APP_BY_ID: Record<SidebarAppId, SidebarApp> = SIDEBAR_APPS.reduce(
|
||||
(acc, app) => {
|
||||
acc[app.id] = app
|
||||
return acc
|
||||
},
|
||||
{} as Record<SidebarFavorite, SidebarApp>
|
||||
{} as Record<SidebarAppId, SidebarApp>
|
||||
)
|
||||
|
||||
export function getSidebarApp(id: SidebarFavorite): SidebarApp | undefined {
|
||||
export function getSidebarApp(id: SidebarAppId): SidebarApp | undefined {
|
||||
return SIDEBAR_APP_BY_ID[id]
|
||||
}
|
||||
|
||||
@@ -179,67 +184,316 @@ export function buildSidebarAppOpenMetadata(app: SidebarApp, key?: string): Tab[
|
||||
* 侧边栏支持的完整菜单顺序。
|
||||
* Preference 默认值可能不包含新菜单,管理态列表仍需要覆盖当前全部支持项。
|
||||
*/
|
||||
export const SIDEBAR_FAVORITE_ORDER: SidebarFavorite[] = SIDEBAR_APPS.map((app) => app.id)
|
||||
export const SIDEBAR_FAVORITE_ORDER: SidebarAppId[] = SIDEBAR_APPS.map((app) => app.id)
|
||||
|
||||
/**
|
||||
* 必须显示的侧边栏收藏项(不能被隐藏)
|
||||
* 这些收藏项必须始终在侧边栏中可见
|
||||
* 抽取为参数方便未来扩展
|
||||
*/
|
||||
export const REQUIRED_SIDEBAR_FAVORITES: SidebarFavorite[] = ['assistants']
|
||||
export const REQUIRED_SIDEBAR_FAVORITES: SidebarAppId[] = ['assistants']
|
||||
|
||||
const sidebarFavoriteSet = new Set<SidebarFavorite>(SIDEBAR_FAVORITE_ORDER)
|
||||
const sidebarFavoriteSet = new Set<SidebarAppId>(SIDEBAR_FAVORITE_ORDER)
|
||||
|
||||
export function getSidebarMenuPath(favorite: SidebarFavorite, defaultPaintingProvider: string): string {
|
||||
export function getSidebarMenuPath(favorite: SidebarAppId, defaultPaintingProvider: string): string {
|
||||
const app = getSidebarApp(favorite)
|
||||
if (!app) return ''
|
||||
return app.resolveUrl?.({ defaultPaintingProvider }) ?? app.routePrefix
|
||||
}
|
||||
|
||||
export function resolveSidebarActiveItem(url: string): SidebarFavorite | '' {
|
||||
const match = SIDEBAR_APPS.find((app) => tabBelongsToApp(app, url))
|
||||
export function resolveSidebarActiveItem(url: string): SidebarAppId | '' {
|
||||
const match = SIDEBAR_APPS.find((app) => (app.exactRouteFocus ? url === app.routePrefix : tabBelongsToApp(app, url)))
|
||||
return match?.id ?? ''
|
||||
}
|
||||
|
||||
export function sanitizeSidebarFavorites(favorites: readonly SidebarFavorite[] | undefined): SidebarFavorite[] {
|
||||
const seen = new Set<SidebarFavorite>()
|
||||
function isSidebarAppId(value: string): value is SidebarAppId {
|
||||
return sidebarFavoriteSet.has(value as SidebarAppId)
|
||||
}
|
||||
|
||||
return (favorites ?? []).filter((favorite) => {
|
||||
if (!sidebarFavoriteSet.has(favorite) || seen.has(favorite)) {
|
||||
return false
|
||||
function createSidebarAppFavorite(id: SidebarAppId): SidebarFavoriteItem {
|
||||
return { type: 'app', id }
|
||||
}
|
||||
|
||||
function createSidebarMiniAppFavorite(id: string): SidebarFavoriteItem {
|
||||
return { type: 'mini_app', id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identity for a favorite — its react key and reorder-matching key.
|
||||
*
|
||||
* Keep the type namespace. Future item types (including `group`) must not collide
|
||||
* with app or mini-app ids.
|
||||
*/
|
||||
export function getSidebarFavoriteKey(favorite: SidebarFavoriteItem): string {
|
||||
return `${favorite.type}:${favorite.id}`
|
||||
}
|
||||
|
||||
function isForwardCompatibleSidebarFavoriteItem(favorite: SidebarFavoriteItem): boolean {
|
||||
const item = favorite as { type?: unknown; id?: unknown }
|
||||
return (
|
||||
typeof item.type === 'string' &&
|
||||
item.type !== 'app' &&
|
||||
item.type !== 'mini_app' &&
|
||||
typeof item.id === 'string' &&
|
||||
item.id.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function getForwardCompatibleSidebarFavoriteItems(
|
||||
favorites: readonly SidebarFavoriteItem[] | undefined
|
||||
): SidebarFavoriteItem[] {
|
||||
const seen = new Set<string>()
|
||||
const items: SidebarFavoriteItem[] = []
|
||||
|
||||
for (const favorite of favorites ?? []) {
|
||||
if (!isForwardCompatibleSidebarFavoriteItem(favorite)) continue
|
||||
|
||||
const item = favorite as SidebarFavoriteItem & { type: string; id: string }
|
||||
const key = `${item.type}:${item.id}`
|
||||
if (seen.has(key)) continue
|
||||
|
||||
seen.add(key)
|
||||
items.push(favorite)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function preserveForwardCompatibleSidebarFavoriteItems(
|
||||
favorites: readonly SidebarFavoriteItem[] | undefined,
|
||||
nextItems: SidebarFavoriteItem[]
|
||||
): SidebarFavoriteItem[] {
|
||||
const futureItems = getForwardCompatibleSidebarFavoriteItems(favorites)
|
||||
return futureItems.length ? [...nextItems, ...futureItems] : nextItems
|
||||
}
|
||||
|
||||
function normalizeSidebarFavoriteItem(favorite: SidebarFavoriteItem): SidebarFavoriteItem | undefined {
|
||||
// Preserve the original item (spread) rather than rebuilding it from its id, so
|
||||
// any future per-item fields survive the normalize round-trip instead of being
|
||||
// silently dropped. Only the id is validated per type.
|
||||
switch (favorite.type) {
|
||||
case 'app':
|
||||
return isSidebarAppId(favorite.id) ? { ...favorite } : undefined
|
||||
case 'mini_app':
|
||||
return favorite.id ? { ...favorite } : undefined
|
||||
default: {
|
||||
// Untrusted storage boundary: an unknown type (corrupt or written by a newer
|
||||
// build) is dropped, not thrown, so a downgrade never crashes. The `never`
|
||||
// binding still makes adding a SidebarFavoriteItem variant a compile error
|
||||
// here until a case is added above.
|
||||
const _exhaustive: never = favorite
|
||||
void _exhaustive
|
||||
return undefined
|
||||
}
|
||||
|
||||
seen.add(favorite)
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function getRequiredSidebarFavoritesVisible(
|
||||
favorites: readonly SidebarFavorite[] | undefined
|
||||
): SidebarFavorite[] {
|
||||
const visible = new Set(sanitizeSidebarFavorites(favorites))
|
||||
/** Normalize and dedupe the stored favorites into valid, ordered tagged items. */
|
||||
export function getSidebarFavoriteItems(favorites: readonly SidebarFavoriteItem[] | undefined): SidebarFavoriteItem[] {
|
||||
const seen = new Set<string>()
|
||||
const items: SidebarFavoriteItem[] = []
|
||||
|
||||
for (const favorite of REQUIRED_SIDEBAR_FAVORITES) {
|
||||
visible.add(favorite)
|
||||
for (const favorite of favorites ?? []) {
|
||||
const item = normalizeSidebarFavoriteItem(favorite)
|
||||
if (!item) continue
|
||||
|
||||
const key = getSidebarFavoriteKey(item)
|
||||
if (seen.has(key)) continue
|
||||
|
||||
seen.add(key)
|
||||
items.push(item)
|
||||
}
|
||||
|
||||
return SIDEBAR_FAVORITE_ORDER.filter((favorite) => visible.has(favorite))
|
||||
return items
|
||||
}
|
||||
|
||||
/** Mini app sidebar favorites: an ordered, deduped list of mini app ids. */
|
||||
export function getSidebarMiniAppFavoriteIds(favorites: readonly SidebarFavoriteItem[] | undefined): string[] {
|
||||
// LEAF-ONLY: recurse into group.items when a 'group' variant is added.
|
||||
return getSidebarFavoriteItems(favorites).flatMap((favorite) => (favorite.type === 'mini_app' ? [favorite.id] : []))
|
||||
}
|
||||
|
||||
/**
|
||||
* The full ordered, deduped sidebar list — apps and mini apps interleaved in
|
||||
* their stored order. Required apps missing from storage are prepended so they
|
||||
* are always visible. This is the single source of truth the sidebar renders
|
||||
* from; every mutation below operates on this list in place, preserving the
|
||||
* mixed order instead of segregating apps before mini apps.
|
||||
*/
|
||||
export function getOrderedVisibleSidebarFavoriteItems(
|
||||
favorites: readonly SidebarFavoriteItem[] | undefined
|
||||
): SidebarFavoriteItem[] {
|
||||
const items = getSidebarFavoriteItems(favorites)
|
||||
// LEAF-ONLY: recurse into group.items when a 'group' variant is added.
|
||||
const missingRequired = REQUIRED_SIDEBAR_FAVORITES.filter(
|
||||
(id) => !items.some((item) => item.type === 'app' && item.id === id)
|
||||
).map(createSidebarAppFavorite)
|
||||
|
||||
return [...missingRequired, ...items]
|
||||
}
|
||||
|
||||
/** Built-in app ids projected out of the mixed list, in order. */
|
||||
export function getOrderedVisibleSidebarFavorites(
|
||||
favorites: readonly SidebarFavorite[] | undefined
|
||||
): SidebarFavorite[] {
|
||||
const visible = sanitizeSidebarFavorites(favorites)
|
||||
favorites: readonly SidebarFavoriteItem[] | undefined
|
||||
): SidebarAppId[] {
|
||||
// LEAF-ONLY: recurse into group.items when a 'group' variant is added.
|
||||
return getOrderedVisibleSidebarFavoriteItems(favorites).flatMap((favorite) =>
|
||||
favorite.type === 'app' ? [favorite.id] : []
|
||||
)
|
||||
}
|
||||
|
||||
for (const favorite of REQUIRED_SIDEBAR_FAVORITES) {
|
||||
if (visible.includes(favorite)) continue
|
||||
// --- Favorites mutations -----------------------------------------------------
|
||||
//
|
||||
// The favorites preference stores apps and mini apps interleaved in one ordered
|
||||
// array. Every mutation operates on the full mixed list (`getOrderedVisible-
|
||||
// SidebarFavoriteItems`) in place: adds append to the end of the whole list,
|
||||
// removes filter out, and reorders permute their target items while leaving the
|
||||
// other type's items exactly where they sit. This keeps the sidebar's mixed
|
||||
// order intact across any mutation, whichever surface (sidebar or launchpad)
|
||||
// triggered it.
|
||||
|
||||
const favoriteOrder = SIDEBAR_FAVORITE_ORDER.indexOf(favorite)
|
||||
const insertIndex = visible.findIndex(
|
||||
(visibleFavorite) => SIDEBAR_FAVORITE_ORDER.indexOf(visibleFavorite) > favoriteOrder
|
||||
)
|
||||
visible.splice(insertIndex === -1 ? visible.length : insertIndex, 0, favorite)
|
||||
/**
|
||||
* Reorder the whole sidebar list to `orderedItems` (a permutation of the visible
|
||||
* favorites). Invalid known items are dropped, future item types are preserved at
|
||||
* the end, and any stored favorite missing from the list (e.g. a stale mini app
|
||||
* id) is kept at the end so a partial order never silently loses favorites.
|
||||
*/
|
||||
export function reorderSidebarFavorites(
|
||||
favorites: readonly SidebarFavoriteItem[] | undefined,
|
||||
orderedItems: readonly SidebarFavoriteItem[]
|
||||
): SidebarFavoriteItem[] {
|
||||
const items = getOrderedVisibleSidebarFavoriteItems(favorites)
|
||||
const byKey = new Map(items.map((item) => [getSidebarFavoriteKey(item), item]))
|
||||
const seen = new Set<string>()
|
||||
const reordered: SidebarFavoriteItem[] = []
|
||||
|
||||
for (const requested of orderedItems) {
|
||||
const key = getSidebarFavoriteKey(requested)
|
||||
const item = byKey.get(key)
|
||||
if (item && !seen.has(key)) {
|
||||
seen.add(key)
|
||||
reordered.push(item)
|
||||
}
|
||||
}
|
||||
for (const item of items) {
|
||||
if (!seen.has(getSidebarFavoriteKey(item))) reordered.push(item)
|
||||
}
|
||||
|
||||
return visible
|
||||
return preserveForwardCompatibleSidebarFavoriteItems(favorites, reordered)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin or unpin a built-in app, preserving everything else in place. Pinning
|
||||
* appends to the end of the list; unpinning a required app is a no-op — required
|
||||
* apps are always visible.
|
||||
*/
|
||||
export function setSidebarAppPinned(
|
||||
favorites: readonly SidebarFavoriteItem[] | undefined,
|
||||
id: SidebarAppId,
|
||||
pinned: boolean
|
||||
): SidebarFavoriteItem[] {
|
||||
const items = getOrderedVisibleSidebarFavoriteItems(favorites)
|
||||
// LEAF-ONLY: recurse into group.items when a 'group' variant is added.
|
||||
const isTarget = (item: SidebarFavoriteItem) => item.type === 'app' && item.id === id
|
||||
|
||||
if (!pinned) {
|
||||
if (REQUIRED_SIDEBAR_FAVORITES.includes(id)) return preserveForwardCompatibleSidebarFavoriteItems(favorites, items)
|
||||
return preserveForwardCompatibleSidebarFavoriteItems(
|
||||
favorites,
|
||||
items.filter((item) => !isTarget(item))
|
||||
)
|
||||
}
|
||||
|
||||
if (items.some(isTarget)) return preserveForwardCompatibleSidebarFavoriteItems(favorites, items)
|
||||
return preserveForwardCompatibleSidebarFavoriteItems(favorites, [...items, createSidebarAppFavorite(id)])
|
||||
}
|
||||
|
||||
/** Toggle a mini app favorite, preserving everything else. Adding appends to the end. */
|
||||
export function toggleSidebarMiniApp(
|
||||
favorites: readonly SidebarFavoriteItem[] | undefined,
|
||||
id: string
|
||||
): SidebarFavoriteItem[] {
|
||||
const items = getOrderedVisibleSidebarFavoriteItems(favorites)
|
||||
// LEAF-ONLY: recurse into group.items when a 'group' variant is added.
|
||||
const isTarget = (item: SidebarFavoriteItem) => item.type === 'mini_app' && item.id === id
|
||||
|
||||
if (items.some(isTarget)) {
|
||||
return preserveForwardCompatibleSidebarFavoriteItems(
|
||||
favorites,
|
||||
items.filter((item) => !isTarget(item))
|
||||
)
|
||||
}
|
||||
return preserveForwardCompatibleSidebarFavoriteItems(favorites, [...items, createSidebarMiniAppFavorite(id)])
|
||||
}
|
||||
|
||||
/** Remove a mini app favorite, preserving everything else in place. */
|
||||
export function removeSidebarMiniApp(
|
||||
favorites: readonly SidebarFavoriteItem[] | undefined,
|
||||
id: string
|
||||
): SidebarFavoriteItem[] {
|
||||
// LEAF-ONLY: recurse into group.items when a 'group' variant is added.
|
||||
return preserveForwardCompatibleSidebarFavoriteItems(
|
||||
favorites,
|
||||
getOrderedVisibleSidebarFavoriteItems(favorites).filter((item) => !(item.type === 'mini_app' && item.id === id))
|
||||
)
|
||||
}
|
||||
|
||||
// --- Launchpad app order --------------------------------------------------
|
||||
//
|
||||
// The launchpad orders its built-in app tiles through its own preference
|
||||
// (`ui.launchpad.app_order`), completely independent of the sidebar favorites
|
||||
// order. Mini app tiles are ordered by their global `orderKey` instead, so the
|
||||
// launchpad never reads or writes `ui.sidebar.favorites`.
|
||||
|
||||
/**
|
||||
* The ordered launchpad app ids. Stored order is filtered to valid app ids and
|
||||
* deduped; any app missing from storage (e.g. an empty default or a newly added
|
||||
* app) is appended in canonical order, so a partial or empty store still yields
|
||||
* every app exactly once.
|
||||
*/
|
||||
export function getOrderedLaunchpadApps(stored: readonly string[] | undefined): SidebarAppId[] {
|
||||
const seen = new Set<SidebarAppId>()
|
||||
const ordered: SidebarAppId[] = []
|
||||
|
||||
for (const id of stored ?? []) {
|
||||
if (isSidebarAppId(id) && !seen.has(id)) {
|
||||
seen.add(id)
|
||||
ordered.push(id)
|
||||
}
|
||||
}
|
||||
for (const id of SIDEBAR_FAVORITE_ORDER) {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id)
|
||||
ordered.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
return ordered
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder the launchpad app list to `orderedIds` (typically the rendered tile
|
||||
* order after a drag). Unknown ids are dropped and any app missing from the
|
||||
* requested order is kept at the end so a partial order never loses apps.
|
||||
*/
|
||||
export function reorderLaunchpadApps(
|
||||
stored: readonly string[] | undefined,
|
||||
orderedIds: readonly string[]
|
||||
): SidebarAppId[] {
|
||||
const current = getOrderedLaunchpadApps(stored)
|
||||
const currentSet = new Set(current)
|
||||
const seen = new Set<SidebarAppId>()
|
||||
const next: SidebarAppId[] = []
|
||||
|
||||
for (const id of orderedIds) {
|
||||
if (isSidebarAppId(id) && currentSet.has(id) && !seen.has(id)) {
|
||||
seen.add(id)
|
||||
next.push(id)
|
||||
}
|
||||
}
|
||||
for (const id of current) {
|
||||
if (!seen.has(id)) next.push(id)
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Auto-generated preferences configuration
|
||||
* Generated at: 2026-06-30T10:49:21.183Z
|
||||
* Generated at: 2026-07-02T13:19:02.397Z
|
||||
*
|
||||
* This file is automatically generated from classification.json
|
||||
* To update this file, modify classification.json and run:
|
||||
@@ -472,10 +472,12 @@ export interface PreferenceSchemas {
|
||||
'topic.tab.show': boolean
|
||||
// redux/settings/customCss
|
||||
'ui.custom_css': string
|
||||
// target-key-definitions/complex/complex
|
||||
'ui.launchpad.app_order': PreferenceTypes.SidebarFavorite[]
|
||||
// redux/settings/navbarPosition
|
||||
'ui.navbar.position': 'left' | 'top'
|
||||
// target-key-definitions/complex/complex
|
||||
'ui.sidebar.favorites': PreferenceTypes.SidebarFavorite[]
|
||||
'ui.sidebar.favorites': PreferenceTypes.SidebarFavoriteItem[]
|
||||
// redux/settings/theme
|
||||
'ui.theme_mode': PreferenceTypes.ThemeMode
|
||||
// redux/settings/userTheme.userCodeFontFamily
|
||||
@@ -737,8 +739,15 @@ export const DefaultPreferences: PreferenceSchemas = {
|
||||
'topic.tab.display_mode': 'time',
|
||||
'topic.tab.show': true,
|
||||
'ui.custom_css': '',
|
||||
'ui.launchpad.app_order': [],
|
||||
'ui.navbar.position': 'top',
|
||||
'ui.sidebar.favorites': ['assistants', 'agents', 'store', 'translate', 'mini_app'],
|
||||
'ui.sidebar.favorites': [
|
||||
{ id: 'assistants', type: 'app' },
|
||||
{ id: 'agents', type: 'app' },
|
||||
{ id: 'store', type: 'app' },
|
||||
{ id: 'translate', type: 'app' },
|
||||
{ id: 'mini_app', type: 'app' }
|
||||
],
|
||||
'ui.theme_mode': PreferenceTypes.ThemeMode.system,
|
||||
'ui.theme_user.code_font_family': '',
|
||||
'ui.theme_user.color_primary': '#00b96b',
|
||||
@@ -751,7 +760,7 @@ export const DefaultPreferences: PreferenceSchemas = {
|
||||
|
||||
/**
|
||||
* 生成统计:
|
||||
* - 总配置项: 225
|
||||
* - 总配置项: 226
|
||||
* - electronStore项: 1
|
||||
* - redux项: 173
|
||||
* - localStorage项: 0
|
||||
|
||||
@@ -99,6 +99,24 @@ export type SidebarFavorite =
|
||||
| 'notes'
|
||||
| 'openclaw'
|
||||
|
||||
/**
|
||||
* Group-ready sidebar storage contract.
|
||||
*
|
||||
* Leaf items are stored as tagged objects, not bare ids. Keep the `type` values,
|
||||
* id semantics, and one ordered heterogeneous top-level array stable: a future
|
||||
* `group` variant can then be added as another top-level item without migrating
|
||||
* existing flat `SidebarFavoriteItem[]` values.
|
||||
*/
|
||||
export type SidebarFavoriteItem =
|
||||
| {
|
||||
type: 'app'
|
||||
id: SidebarFavorite
|
||||
}
|
||||
| {
|
||||
type: 'mini_app'
|
||||
id: string
|
||||
}
|
||||
|
||||
export type AssistantIconType = 'model' | 'emoji' | 'none'
|
||||
|
||||
export type ProxyMode = 'system' | 'custom' | 'none'
|
||||
|
||||
@@ -175,6 +175,16 @@ vi.mock('@cherrystudio/ui', () => {
|
||||
React.createElement('div', { key: getId(item) }, renderItem(item, index, { dragging: false }))
|
||||
)
|
||||
),
|
||||
Sortable: ({ items, itemKey, renderItem, className }) => {
|
||||
const getKey = typeof itemKey === 'function' ? itemKey : (item) => item[itemKey]
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ className },
|
||||
items.map((item) =>
|
||||
React.createElement('div', { key: getKey(item) }, renderItem(item, { dragging: false, overlay: false }))
|
||||
)
|
||||
)
|
||||
},
|
||||
NormalTooltip: ({ children }) => children,
|
||||
Button: ({ children, onPress, disabled, isDisabled, loading, startContent, asChild, ...props }) => {
|
||||
const buttonProps = { ...props, onClick: onPress ?? props.onClick, disabled: disabled || isDisabled || loading }
|
||||
|
||||
@@ -119,10 +119,23 @@
|
||||
},
|
||||
{
|
||||
"targetKey": "ui.sidebar.favorites",
|
||||
"type": "PreferenceTypes.SidebarFavorite[]",
|
||||
"defaultValue": ["assistants", "agents", "store", "translate", "mini_app"],
|
||||
"type": "PreferenceTypes.SidebarFavoriteItem[]",
|
||||
"defaultValue": [
|
||||
{ "id": "assistants", "type": "app" },
|
||||
{ "id": "agents", "type": "app" },
|
||||
{ "id": "store", "type": "app" },
|
||||
{ "id": "translate", "type": "app" },
|
||||
{ "id": "mini_app", "type": "app" }
|
||||
],
|
||||
"status": "classified",
|
||||
"description": "Left sidebar favorite app ids, stored as an ordered subset of the sidebar app catalog"
|
||||
"description": "Left sidebar favorites, an ordered list of tagged items: built-in apps ({type:'app'}) and mini apps ({type:'mini_app'})"
|
||||
},
|
||||
{
|
||||
"targetKey": "ui.launchpad.app_order",
|
||||
"type": "PreferenceTypes.SidebarFavorite[]",
|
||||
"defaultValue": [],
|
||||
"status": "classified",
|
||||
"description": "Launchpad built-in app tile order, independent from the sidebar favorites order (new v2 preference, no v1 source). Empty defaults to the canonical app order; unknown/missing apps are normalized at read time"
|
||||
},
|
||||
{
|
||||
"targetKey": "chat.default_model_id",
|
||||
|
||||
Reference in New Issue
Block a user