Merge remote-tracking branch 'origin/main' into codex/office-suite-parse-preview

This commit is contained in:
jd
2026-07-03 11:57:44 +08:00
47 changed files with 2914 additions and 737 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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')
})
})

View File

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

View File

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

View File

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

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

View File

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

View 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)}
/>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -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": {

View File

@@ -2929,8 +2929,8 @@
"apps": "应用",
"minapps": "小程序",
"miniApps": "小程序",
"pin_to_sidebar": "固定到侧边栏",
"unpin_from_sidebar": "取消固定"
"pin_to_sidebar": "添加到侧边栏",
"unpin_from_sidebar": "从侧边栏移除"
},
"library": {
"action": {

View File

@@ -2929,8 +2929,8 @@
"apps": "應用",
"minapps": "小程式",
"miniApps": "小程式",
"pin_to_sidebar": "固定到側邊欄",
"unpin_from_sidebar": "取消固定"
"pin_to_sidebar": "新增到側邊欄",
"unpin_from_sidebar": "從側邊欄移除"
},
"library": {
"action": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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