From a1a659e690e32e02ea0c860c0bc94a885bba2886 Mon Sep 17 00:00:00 2001 From: jd <59188306+zhangjiadi225@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:39:33 +0800 Subject: [PATCH] feat(sidebar): mixed sidebar list with launchpad ordering decoupled (#16400) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com> Signed-off-by: jd <59188306+zhangjiadi225@users.noreply.github.com> --- .../mappings/ComplexPreferenceMappings.ts | 4 +- .../ComplexPreferenceMappings.test.ts | 91 ++-- .../CodeBlockView/HtmlPreviewFrame.tsx | 1 + .../__tests__/GlobalSearchPanel.test.tsx | 12 +- src/renderer/components/Icons/MiniAppIcon.tsx | 24 +- .../Icons/__tests__/MiniAppIcon.test.tsx | 12 +- src/renderer/components/MiniApp/MiniApp.tsx | 13 + .../MiniApp/__tests__/MiniApp.test.tsx | 177 +++++++ .../Selector/model/useModelSelectorData.ts | 26 +- src/renderer/components/Sidebar/Sidebar.tsx | 88 ++-- .../components/Sidebar/SidebarDocked.tsx | 114 ----- .../components/Sidebar/SidebarList.tsx | 115 +++++ .../components/Sidebar/SidebarMenu.tsx | 109 ----- .../Sidebar/SidebarSortableList.tsx | 73 +++ .../Sidebar/__tests__/Sidebar.test.tsx | 384 ++++++++++++++-- .../components/Sidebar/primitives.tsx | 30 +- src/renderer/components/Sidebar/types.ts | 45 +- src/renderer/components/app/Sidebar.tsx | 152 ++++-- .../app/__tests__/Sidebar.language.test.tsx | 8 +- .../components/app/__tests__/Sidebar.test.tsx | 365 ++++++++++++++- src/renderer/components/app/sidebarIcons.tsx | 8 +- .../components/app/sidebarVariants.tsx | 121 +++++ .../actions/ResourceListActionContextMenu.tsx | 7 +- .../ResourceListActionContextMenu.test.tsx | 9 +- .../chat/messages/blocks/ErrorBlock.tsx | 22 +- .../__tests__/messageMenuBarActions.test.tsx | 48 +- .../components/command/CommandMenus.tsx | 8 +- .../command/__tests__/CommandMenus.test.tsx | 22 + .../components/layout/TabsProvider.tsx | 10 +- .../layout/__tests__/tabIcons.test.ts | 6 +- src/renderer/components/layout/tabIcons.ts | 2 + .../hooks/useConversationNavigation.ts | 14 +- src/renderer/hooks/useLaunchpadAppOrder.ts | 31 ++ src/renderer/hooks/useSidebarFavorites.ts | 66 +++ src/renderer/i18n/locales/en-us.json | 4 +- src/renderer/i18n/locales/zh-cn.json | 4 +- src/renderer/i18n/locales/zh-tw.json | 4 +- .../pages/launchpad/LaunchpadPage.tsx | 304 ++++++------ .../__tests__/LaunchpadPage.test.tsx | 433 +++++++++++++++++- src/renderer/pages/miniApps/MiniAppPage.tsx | 17 +- .../miniApps/__tests__/MiniAppPage.test.tsx | 20 + src/renderer/utils/__tests__/sidebar.test.ts | 248 +++++++++- src/renderer/utils/sidebar.ts | 336 ++++++++++++-- .../data/preference/preferenceSchemas.ts | 17 +- src/shared/data/preference/preferenceTypes.ts | 18 + tests/renderer.setup.ts | 10 + .../data/target-key-definitions.json | 19 +- 47 files changed, 2914 insertions(+), 737 deletions(-) create mode 100644 src/renderer/components/MiniApp/__tests__/MiniApp.test.tsx delete mode 100644 src/renderer/components/Sidebar/SidebarDocked.tsx create mode 100644 src/renderer/components/Sidebar/SidebarList.tsx delete mode 100644 src/renderer/components/Sidebar/SidebarMenu.tsx create mode 100644 src/renderer/components/Sidebar/SidebarSortableList.tsx create mode 100644 src/renderer/components/app/sidebarVariants.tsx create mode 100644 src/renderer/hooks/useLaunchpadAppOrder.ts create mode 100644 src/renderer/hooks/useSidebarFavorites.ts diff --git a/src/main/data/migration/v2/migrators/mappings/ComplexPreferenceMappings.ts b/src/main/data/migration/v2/migrators/mappings/ComplexPreferenceMappings.ts index de1083202e..efffdafa96 100644 --- a/src/main/data/migration/v2/migrators/mappings/ComplexPreferenceMappings.ts +++ b/src/main/data/migration/v2/migrators/mappings/ComplexPreferenceMappings.ts @@ -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) } } }, diff --git a/src/main/data/migration/v2/migrators/mappings/__tests__/ComplexPreferenceMappings.test.ts b/src/main/data/migration/v2/migrators/mappings/__tests__/ComplexPreferenceMappings.test.ts index da3355c537..935a0b5cb1 100644 --- a/src/main/data/migration/v2/migrators/mappings/__tests__/ComplexPreferenceMappings.test.ts +++ b/src/main/data/migration/v2/migrators/mappings/__tests__/ComplexPreferenceMappings.test.ts @@ -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') + ] }) }) diff --git a/src/renderer/components/CodeBlockView/HtmlPreviewFrame.tsx b/src/renderer/components/CodeBlockView/HtmlPreviewFrame.tsx index 0e17d7889f..10e2e72745 100644 --- a/src/renderer/components/CodeBlockView/HtmlPreviewFrame.tsx +++ b/src/renderer/components/CodeBlockView/HtmlPreviewFrame.tsx @@ -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' diff --git a/src/renderer/components/GlobalSearch/__tests__/GlobalSearchPanel.test.tsx b/src/renderer/components/GlobalSearch/__tests__/GlobalSearchPanel.test.tsx index 49644e92da..075c25577c 100644 --- a/src/renderer/components/GlobalSearch/__tests__/GlobalSearchPanel.test.tsx +++ b/src/renderer/components/GlobalSearch/__tests__/GlobalSearchPanel.test.tsx @@ -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, 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 = { diff --git a/src/renderer/components/Icons/MiniAppIcon.tsx b/src/renderer/components/Icons/MiniAppIcon.tsx index fa091f9d00..f18c10d2c5 100644 --- a/src/renderer/components/Icons/MiniAppIcon.tsx +++ b/src/renderer/components/Icons/MiniAppIcon.tsx @@ -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 + /** `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 = ({ app, appearance = 'avatar', size = 48, style, sidebar = false }) => { +const MiniAppIcon: FC = ({ 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 ( = ({ app, appearance = 'avatar', size = 48, style, return } + if (appearance === 'bare') { + return ( + {app.name + ) + } + return ( = ({ app, appearance = 'avatar', size = 48, style, height: `${size}px`, backgroundColor: app.background, userSelect: 'none', - ...(sidebar ? {} : undefined), ...style }} draggable={false} diff --git a/src/renderer/components/Icons/__tests__/MiniAppIcon.test.tsx b/src/renderer/components/Icons/__tests__/MiniAppIcon.test.tsx index 61c400018d..0f3d12b6df 100644 --- a/src/renderer/components/Icons/__tests__/MiniAppIcon.test.tsx +++ b/src/renderer/components/Icons/__tests__/MiniAppIcon.test.tsx @@ -48,7 +48,7 @@ describe('MiniAppIcon', () => { it('should render correctly with various props', () => { const customStyle = { marginTop: '10px' } - const { container } = render() + const { container } = render() 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() - 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, diff --git a/src/renderer/components/MiniApp/MiniApp.tsx b/src/renderer/components/MiniApp/MiniApp.tsx index 4e5236d7e2..8d1a84a1c6 100644 --- a/src/renderer/components/MiniApp/MiniApp.tsx +++ b/src/renderer/components/MiniApp/MiniApp.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 } diff --git a/src/renderer/components/MiniApp/__tests__/MiniApp.test.tsx b/src/renderer/components/MiniApp/__tests__/MiniApp.test.tsx new file mode 100644 index 0000000000..68332d87f2 --- /dev/null +++ b/src/renderer/components/MiniApp/__tests__/MiniApp.test.tsx @@ -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 ?
: 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 }> + }) => ( +
+ {children} + {extraItems.map((item) => ( + + ))} +
+ ) +})) + +vi.mock('@renderer/components/Icons/MiniAppIcon', () => ({ + default: ({ app }: { app: MiniAppType }) =>
+})) + +vi.mock('@renderer/components/IndicatorLight', () => ({ + default: () =>
+})) + +vi.mock('@renderer/components/MarqueeText', () => ({ + default: ({ children }: { children: ReactNode }) => {children} +})) + +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() + 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() + 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() + 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() + fireEvent.click(screen.getByRole('button', { name: 'miniApp.remove_from_launchpad' })) + + expect(mocks.updateAppStatus).toHaveBeenCalledWith('calculator', 'enabled') + }) +}) diff --git a/src/renderer/components/Selector/model/useModelSelectorData.ts b/src/renderer/components/Selector/model/useModelSelectorData.ts index d4e95ad34a..ff4fa738e9 100644 --- a/src/renderer/components/Selector/model/useModelSelectorData.ts +++ b/src/renderer/components/Selector/model/useModelSelectorData.ts @@ -195,28 +195,6 @@ export function useModelSelectorData({ [modelsByProvider, searchText] ) - const filteredModelsByProvider = useMemo(() => { - const nextFilteredModels = new Map() - - 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, diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 9f02c3b855..ce85d8dd54 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -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 | 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({
) - 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() }}>
{renderLogo()} @@ -131,8 +153,7 @@ export function Sidebar({ )}
- - +
{showFooter && ( @@ -215,8 +236,7 @@ export function Sidebar({ {/* Content */}
- - +
{/* Footer */} diff --git a/src/renderer/components/Sidebar/SidebarDocked.tsx b/src/renderer/components/Sidebar/SidebarDocked.tsx deleted file mode 100644 index ac8213f6e4..0000000000 --- a/src/renderer/components/Sidebar/SidebarDocked.tsx +++ /dev/null @@ -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 - return -} - -type DockedTabsProps = Omit - -function IconDockedTabs({ - dockedTabs, - activeTabId, - onMiniAppTabClick, - onStartSidebarDrag, - onCloseDockedTab -}: DockedTabsProps) { - return ( -
- {dockedTabs.map((dockedTab) => { - const isActive = activeTabId === dockedTab.id - - return ( -
- - - - - -
- ) - })} -
- ) -} - -function FullDockedTabs({ - dockedTabs, - activeTabId, - onMiniAppTabClick, - onStartSidebarDrag, - onCloseDockedTab -}: DockedTabsProps) { - return ( -
- {dockedTabs.map((dockedTab) => { - const isActive = activeTabId === dockedTab.id - - return ( -
onMiniAppTabClick?.(dockedTab.id)} - onMouseDown={(event) => { - event.stopPropagation() - onStartSidebarDrag?.(event, dockedTab.id) - }}> - {isActive && } - - {dockedTab.title} - -
- ) - })} -
- ) -} diff --git a/src/renderer/components/Sidebar/SidebarList.tsx b/src/renderer/components/Sidebar/SidebarList.tsx new file mode 100644 index 0000000000..c910602130 --- /dev/null +++ b/src/renderer/components/Sidebar/SidebarList.tsx @@ -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 + return +} + +type ListProps = Omit + +function EntryContextMenu({ + children, + items, + onOpenChange +}: { + children: ReactNode + items?: ResolvedSidebarEntry['contextMenuItems'] + onOpenChange?: (open: boolean) => void +}) { + if (!items?.length) return <>{children} + + return ( + + {children} + + ) +} + +function IconList({ entries, active, onReorder, onContextMenuOpenChange }: ListProps) { + return ( + + {(entry, guardClick) => { + const isActive = entry.isActive(active) + + return ( + + + + + + ) + }} + + ) +} + +function FullList({ entries, active, onReorder, onContextMenuOpenChange }: ListProps) { + return ( + + {(entry, guardClick: SidebarClickGuard) => { + const isActive = entry.isActive(active) + + return ( +
+ + + + {isActive && } +
+ ) + }} +
+ ) +} diff --git a/src/renderer/components/Sidebar/SidebarMenu.tsx b/src/renderer/components/Sidebar/SidebarMenu.tsx deleted file mode 100644 index d9a9ed2fdd..0000000000 --- a/src/renderer/components/Sidebar/SidebarMenu.tsx +++ /dev/null @@ -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 - onMiniAppTabClick?: (tabId: string) => void -} - -export function SidebarMenu({ layout, ...props }: SidebarMenuProps) { - if (layout === 'icon') return - return -} - -type MenuItemsProps = Omit - -function IconMenuItems({ items, activeItem, activeTabId, onItemClick, onMiniAppTabClick }: MenuItemsProps) { - return ( -
- {items.map((item) => { - const isActive = activeItem === item.id - const Icon = item.icon - const miniTabs = item.miniAppTabs ?? [] - - return ( -
- - - - - {miniTabs.map((miniTab) => ( - - - - ))} -
- ) - })} -
- ) -} - -function FullMenuItems({ items, activeItem, activeTabId, onItemClick, onMiniAppTabClick }: MenuItemsProps) { - return ( -
- {items.map((item) => { - const isActive = activeItem === item.id - const Icon = item.icon - const miniTabs = item.miniAppTabs ?? [] - - return ( -
-
- } - label={item.label} - active={isActive} - onClick={() => void onItemClick(item.id)} - className="rounded-xl data-[active=true]:bg-sidebar-active-bg" - /> - {isActive && } -
- - {miniTabs.map((miniTab) => ( - - ))} -
- ) - })} -
- ) -} diff --git a/src/renderer/components/Sidebar/SidebarSortableList.tsx b/src/renderer/components/Sidebar/SidebarSortableList.tsx new file mode 100644 index 0000000000..f3e89204c5 --- /dev/null +++ b/src/renderer/components/Sidebar/SidebarSortableList.tsx @@ -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 { + 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({ + items, + itemKey, + className, + onReorder, + children +}: SidebarSortableListProps) { + const suppressClickUntilRef = useRef(0) + const draggedItemIdRef = useRef(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( + (item, handler) => () => { + if (String(item) === draggedItemIdRef.current && Date.now() < suppressClickUntilRef.current) return + handler() + }, + [] + ) + + if (!onReorder) { + return
{items.map((item) => children(item, guardClick))}
+ } + + return ( + children(item, guardClick)} + /> + ) +} diff --git a/src/renderer/components/Sidebar/__tests__/Sidebar.test.tsx b/src/renderer/components/Sidebar/__tests__/Sidebar.test.tsx index 7acf7b612b..8389bdba85 100644 --- a/src/renderer/components/Sidebar/__tests__/Sidebar.test.tsx +++ b/src/renderer/components/Sidebar/__tests__/Sidebar.test.tsx @@ -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 + }) => ( + + ), + Sortable: ({ items, itemKey, renderItem, ...props }: any) => { + uiMocks.sortableCalls.push({ items, itemKey, renderItem, ...props }) + const getKey = typeof itemKey === 'function' ? itemKey : (item: any) => item[itemKey] + + return ( +
+ {items.map((item: any) => ( +
{renderItem(item)}
+ ))} +
+ ) + } +})) 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 + }) => ( +
+ {children} + {onOpenChange && ( + <> + + ))} +
+ ) +})) + +vi.mock('@renderer/components/Icons/miniAppsLogo', () => ({ + getMiniAppsLogo: (logo?: string) => { + if (logo !== 'qwen') return undefined + + const QwenLogo = ({ style, ...props }: { style?: CSSProperties }) => ( + + ) + QwenLogo.Avatar = ({ size }: { size: number }) => ( + + ) + 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 + }, + 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) => , + 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[]) { @@ -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( - + ) 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( - + ) expect(container.firstElementChild).toHaveStyle({ width: `${INTERMEDIATE_WIDTH}px` }) @@ -159,9 +280,8 @@ describe('Sidebar resize handle', () => { ) @@ -187,19 +307,228 @@ describe('Sidebar resize handle', () => { it('renders the full layout at the full threshold', () => { const { container, getByText } = render( - + ) 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( + + ) + + 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( + + ) + + 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( + + ) + + 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( + + ) + + // 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( + + ) + + 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( + + ) + + expect(screen.getByRole('button', { name: 'Custom Tool' })).toBeInTheDocument() + }) + + it('wires context menu actions for docked mini app icons', () => { + const onRemove = vi.fn() + + render( + + ) + + 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( + + ) + + 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') => @@ -207,10 +536,9 @@ describe('Sidebar resize handle', () => { ) @@ -220,10 +548,9 @@ describe('Sidebar resize handle', () => { ) @@ -236,10 +563,9 @@ describe('Sidebar resize handle', () => { ) diff --git a/src/renderer/components/Sidebar/primitives.tsx b/src/renderer/components/Sidebar/primitives.tsx index f26a6080fb..9539664e64 100644 --- a/src/renderer/components/Sidebar/primitives.tsx +++ b/src/renderer/components/Sidebar/primitives.tsx @@ -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 + return } + 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 (
- } - const Icon = tab.icon - return -} - /** 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 ( diff --git a/src/renderer/components/Sidebar/types.ts b/src/renderer/components/Sidebar/types.ts index c37c747e61..7a308da321 100644 --- a/src/renderer/components/Sidebar/types.ts +++ b/src/renderer/components/Sidebar/types.ts @@ -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 diff --git a/src/renderer/components/app/Sidebar.tsx b/src/renderer/components/app/Sidebar.tsx index 29de8a62af..b98b45cdea 100644 --- a/src/renderer/components/app/Sidebar.tsx +++ b/src/renderer/components/app/Sidebar.tsx @@ -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(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 }) { 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 }) { // Menu items const pathname = activeTab?.url || '/' + const activeMiniAppId = getMiniAppIdFromUrl(activeTab?.url) + const openableMiniAppById = useMemo(() => { + const appById = new Map() + 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( - () => - 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 }) { 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( + () => ({ + 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) => ( ), - dockedTabs: [], - onItemClick: handleNavigate, - onCloseDockedTab: noop + onEntriesReorder: handleReorder } return ( diff --git a/src/renderer/components/app/__tests__/Sidebar.language.test.tsx b/src/renderer/components/app/__tests__/Sidebar.language.test.tsx index d4de58a94b..fcf53e4a22 100644 --- a/src/renderer/components/app/__tests__/Sidebar.language.test.tsx +++ b/src/renderer/components/app/__tests__/Sidebar.language.test.tsx @@ -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) }) diff --git a/src/renderer/components/app/__tests__/Sidebar.test.tsx b/src/renderer/components/app/__tests__/Sidebar.test.tsx index 93f4b054c4..97a560a12a 100644 --- a/src/renderer/components/app/__tests__/Sidebar.test.tsx +++ b/src/renderer/components/app/__tests__/Sidebar.test.tsx @@ -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 } +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 ? (
@@ -167,17 +213,52 @@ vi.mock('../../Sidebar', () => ({
{items?.map((item) => ( - +
+ + {item.contextMenuItems?.map((menuItem) => ( + + ))} +
+ ))} +
+
+ {dockedTabs?.map((miniTab) => ( +
+ + {miniTab.contextMenuItems?.map((menuItem) => ( + + ))} +
))}
) + } })) 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() @@ -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() + + 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() + + 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() + + 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() + + 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() + // 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() + // 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() + // 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() + + 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() + + 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() + + 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() + 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() + 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() + 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') diff --git a/src/renderer/components/app/sidebarIcons.tsx b/src/renderer/components/app/sidebarIcons.tsx index fc7dc55b13..d70db3b79e 100644 --- a/src/renderer/components/app/sidebarIcons.tsx +++ b/src/renderer/components/app/sidebarIcons.tsx @@ -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 = { +export const SIDEBAR_ICON_COMPONENTS: Record = { assistants: MessageSquare, agents: MousePointerClick, paintings: Palette, diff --git a/src/renderer/components/app/sidebarVariants.tsx b/src/renderer/components/app/sidebarVariants.tsx new file mode 100644 index 0000000000..f55f81d76f --- /dev/null +++ b/src/renderer/components/app/sidebarVariants.tsx @@ -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 + 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 { + resolve: (item: T, ctx: SidebarVariantContext) => ResolvedSidebarEntry | null +} + +const appVariant: SidebarVariantDescriptor> = { + 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) => , + 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> = { + 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) => , + 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) + } +} diff --git a/src/renderer/components/chat/actions/ResourceListActionContextMenu.tsx b/src/renderer/components/chat/actions/ResourceListActionContextMenu.tsx index afd2de4833..65513b1712 100644 --- a/src/renderer/components/chat/actions/ResourceListActionContextMenu.tsx +++ b/src/renderer/components/chat/actions/ResourceListActionContextMenu.tsx @@ -58,10 +58,9 @@ export function ResourceListActionContextMenu 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 ( diff --git a/src/renderer/components/chat/actions/__tests__/ResourceListActionContextMenu.test.tsx b/src/renderer/components/chat/actions/__tests__/ResourceListActionContextMenu.test.tsx index 7c207ba8b1..c655aa7362 100644 --- a/src/renderer/components/chat/actions/__tests__/ResourceListActionContextMenu.test.tsx +++ b/src/renderer/components/chat/actions/__tests__/ResourceListActionContextMenu.test.tsx @@ -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 }) => ( {children} @@ -35,9 +34,7 @@ describe('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') diff --git a/src/renderer/components/chat/messages/blocks/ErrorBlock.tsx b/src/renderer/components/chat/messages/blocks/ErrorBlock.tsx index 722ebcfb52..3d51bf209a 100644 --- a/src/renderer/components/chat/messages/blocks/ErrorBlock.tsx +++ b/src/renderer/components/chat/messages/blocks/ErrorBlock.tsx @@ -90,15 +90,25 @@ const MessageErrorInfo: React.FC<{ (error as Record | undefined)?.status ?? (error as Record | undefined)?.statusCode const errorProviderId = (error as Record | undefined)?.providerId as string | undefined const errorModelId = (error as Record | 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 diff --git a/src/renderer/components/chat/messages/frame/__tests__/messageMenuBarActions.test.tsx b/src/renderer/components/chat/messages/frame/__tests__/messageMenuBarActions.test.tsx index 91266f930c..c3627caa6d 100644 --- a/src/renderer/components/chat/messages/frame/__tests__/messageMenuBarActions.test.tsx +++ b/src/renderer/components/chat/messages/frame/__tests__/messageMenuBarActions.test.tsx @@ -138,7 +138,7 @@ import { const t = ((key: string) => key) as any -function createContext(overrides: Partial = {}): MessageMenuBarActionContext { +function createActionContext(overrides: Partial = {}): MessageMenuBarActionContext { const baseActions = { copyText: vi.fn(), copyImage: vi.fn(), @@ -193,7 +193,7 @@ function createContext(overrides: Partial = {}): 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 }) =>
{trigger}
) - 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 }) diff --git a/src/renderer/components/command/CommandMenus.tsx b/src/renderer/components/command/CommandMenus.tsx index 40f0f2ef14..1cf437374f 100644 --- a/src/renderer/components/command/CommandMenus.tsx +++ b/src/renderer/components/command/CommandMenus.tsx @@ -459,6 +459,7 @@ export function CommandContextMenu({ } const requestId = extraItemsRequestIdRef.current + 1 extraItemsRequestIdRef.current = requestId + onOpenChange?.(true) let nativeExtraItems: MaybePromise 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)) { diff --git a/src/renderer/components/command/__tests__/CommandMenus.test.tsx b/src/renderer/components/command/__tests__/CommandMenus.test.tsx index 3273dc4b1a..c31a6906b3 100644 --- a/src/renderer/components/command/__tests__/CommandMenus.test.tsx +++ b/src/renderer/components/command/__tests__/CommandMenus.test.tsx @@ -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 @@ -201,6 +203,7 @@ function renderMenu({ location={location} extraItems={extraItems} pendingExtraItems={pendingExtraItems} + onOpenChange={onOpenChange} getExtraItems={getExtraItems}> @@ -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' }) diff --git a/src/renderer/components/layout/TabsProvider.tsx b/src/renderer/components/layout/TabsProvider.tsx index 765017af0b..c14d074fed 100644 --- a/src/renderer/components/layout/TabsProvider.tsx +++ b/src/renderer/components/layout/TabsProvider.tsx @@ -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/) 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) } } diff --git a/src/renderer/components/layout/__tests__/tabIcons.test.ts b/src/renderer/components/layout/__tests__/tabIcons.test.ts index 5af7361161..db74f60e5a 100644 --- a/src/renderer/components/layout/__tests__/tabIcons.test.ts +++ b/src/renderer/components/layout/__tests__/tabIcons.test.ts @@ -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) }) diff --git a/src/renderer/components/layout/tabIcons.ts b/src/renderer/components/layout/tabIcons.ts index 33d9d2ad02..2eef2eab6e 100644 --- a/src/renderer/components/layout/tabIcons.ts +++ b/src/renderer/components/layout/tabIcons.ts @@ -11,6 +11,7 @@ import { MousePointerClick, NotepadText, Palette, + Rocket, Settings } from 'lucide-react' @@ -26,6 +27,7 @@ export const ROUTE_ICONS: Record = { '/app/paintings': Palette, '/app/translate': Languages, '/app/mini-app': LayoutGrid, + '/app/launchpad': Rocket, '/app/knowledge': FileSearch, '/app/library': Library, '/app/files': Folder, diff --git a/src/renderer/hooks/useConversationNavigation.ts b/src/renderer/hooks/useConversationNavigation.ts index 2eafc16280..37477183f7 100644 --- a/src/renderer/hooks/useConversationNavigation.ts +++ b/src/renderer/hooks/useConversationNavigation.ts @@ -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' diff --git a/src/renderer/hooks/useLaunchpadAppOrder.ts b/src/renderer/hooks/useLaunchpadAppOrder.ts new file mode 100644 index 0000000000..31d8ed23df --- /dev/null +++ b/src/renderer/hooks/useLaunchpadAppOrder.ts @@ -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 } +} diff --git a/src/renderer/hooks/useSidebarFavorites.ts b/src/renderer/hooks/useSidebarFavorites.ts new file mode 100644 index 0000000000..2bd2eb268b --- /dev/null +++ b/src/renderer/hooks/useSidebarFavorites.ts @@ -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 + } +} diff --git a/src/renderer/i18n/locales/en-us.json b/src/renderer/i18n/locales/en-us.json index d2365def7a..80b63b5f0b 100644 --- a/src/renderer/i18n/locales/en-us.json +++ b/src/renderer/i18n/locales/en-us.json @@ -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": { diff --git a/src/renderer/i18n/locales/zh-cn.json b/src/renderer/i18n/locales/zh-cn.json index febc7ab666..31505034a5 100644 --- a/src/renderer/i18n/locales/zh-cn.json +++ b/src/renderer/i18n/locales/zh-cn.json @@ -2929,8 +2929,8 @@ "apps": "应用", "minapps": "小程序", "miniApps": "小程序", - "pin_to_sidebar": "固定到侧边栏", - "unpin_from_sidebar": "取消固定" + "pin_to_sidebar": "添加到侧边栏", + "unpin_from_sidebar": "从侧边栏移除" }, "library": { "action": { diff --git a/src/renderer/i18n/locales/zh-tw.json b/src/renderer/i18n/locales/zh-tw.json index 979f1689eb..2b011f4f4e 100644 --- a/src/renderer/i18n/locales/zh-tw.json +++ b/src/renderer/i18n/locales/zh-tw.json @@ -2929,8 +2929,8 @@ "apps": "應用", "minapps": "小程式", "miniApps": "小程式", - "pin_to_sidebar": "固定到側邊欄", - "unpin_from_sidebar": "取消固定" + "pin_to_sidebar": "新增到側邊欄", + "unpin_from_sidebar": "從側邊欄移除" }, "library": { "action": { diff --git a/src/renderer/pages/launchpad/LaunchpadPage.tsx b/src/renderer/pages/launchpad/LaunchpadPage.tsx index e54f071888..b3d2ee8db7 100644 --- a/src/renderer/pages/launchpad/LaunchpadPage.tsx +++ b/src/renderer/pages/launchpad/LaunchpadPage.tsx @@ -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(REQUIRED_SIDEBAR_FAVORITES) +const REQUIRED_SIDEBAR_FAVORITE_SET = new Set(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 = { +const APP_ICON_BACKGROUNDS: Record = { 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 = { 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(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: , - text: t(getSidebarIconLabelKey(icon)), - bgColor: APP_ICON_BACKGROUNDS[icon], - menuItems: getAppContextMenuItems(icon) - } - ] - }) + return [ + { + id: favorite, + icon: , + 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]) => ( + + + + ) + + const renderMiniAppItem = (app: MiniAppType) => ( +
+ +
+ ) return (
@@ -189,42 +230,38 @@ export default function LaunchpadPage() {

{t('launchpad.apps')}

-
- {appMenuItems.map((item) => ( - - - - ))} +
+ renderAppMenuItem(item)} + />
- {sortedMiniApps.length > 0 && ( + {launchpadMiniAppsVisible && (

{t('launchpad.miniApps')}

-
- {sortedMiniApps.map((app) => ( -
- -
- ))} +
+ renderMiniAppItem(app)} + />
)} @@ -233,3 +270,12 @@ export default function LaunchpadPage() {
) } + +/** 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 +} diff --git a/src/renderer/pages/launchpad/__tests__/LaunchpadPage.test.tsx b/src/renderer/pages/launchpad/__tests__/LaunchpadPage.test.tsx index 3457b3b78a..d6e9410dae 100644 --- a/src/renderer/pages/launchpad/__tests__/LaunchpadPage.test.tsx +++ b/src/renderer/pages/launchpad/__tests__/LaunchpadPage.test.tsx @@ -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 ( +
+ {items.map((item: any) => ( +
{renderItem(item, { dragging: false, overlay: false })}
+ ))} +
+ ) + } })) 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() + + 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() + + 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() + + 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() + + 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() @@ -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() + + 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() + + 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() + + act(() => { + const miniAppSortable = mocks.sortableCalls.find((call) => call.itemKey === 'appId') + miniAppSortable.onSortEnd({ oldIndex: 0, newIndex: 1 }) + }) + + mocks.pinnedMiniApps = [docs, weather] + rerender() + + 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() + + 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() + + 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() + + expect(screen.getByRole('button', { name: 'Calculator' })).toBeInTheDocument() + + mocks.pinnedMiniApps = [{ ...calculator, name: 'Calculator Pro' }] + rerender() + + 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() + + 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() + + // 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() + + 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() - 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() - 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')]) }) }) diff --git a/src/renderer/pages/miniApps/MiniAppPage.tsx b/src/renderer/pages/miniApps/MiniAppPage.tsx index 8eea5a494c..d9732040eb 100644 --- a/src/renderer/pages/miniApps/MiniAppPage.tsx +++ b/src/renderer/pages/miniApps/MiniAppPage.tsx @@ -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 diff --git a/src/renderer/pages/miniApps/__tests__/MiniAppPage.test.tsx b/src/renderer/pages/miniApps/__tests__/MiniAppPage.test.tsx index 09591e702c..c4f0ebcf6a 100644 --- a/src/renderer/pages/miniApps/__tests__/MiniAppPage.test.tsx +++ b/src/renderer/pages/miniApps/__tests__/MiniAppPage.test.tsx @@ -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() + + await waitFor(() => + expect(mocks.updateTab).toHaveBeenCalledWith('launchpad-tab', { + title: 'ChatGPT', + icon: 'chat-logo' + }) + ) + expect(mocks.openMiniAppKeepAlive).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/utils/__tests__/sidebar.test.ts b/src/renderer/utils/__tests__/sidebar.test.ts index e82af9d2b1..7ffcfdbec9 100644 --- a/src/renderer/utils/__tests__/sidebar.test.ts +++ b/src/renderer/utils/__tests__/sidebar.test.ts @@ -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') + }) }) diff --git a/src/renderer/utils/sidebar.ts b/src/renderer/utils/sidebar.ts index 232916f68d..033e890cc2 100644 --- a/src/renderer/utils/sidebar.ts +++ b/src/renderer/utils/sidebar.ts @@ -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: 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 = SIDEBAR_APPS.reduce( +export type SidebarAppId = (typeof SIDEBAR_APP_DEFINITIONS)[number]['id'] +export type SidebarApp = SidebarAppDefinition + +export const SIDEBAR_APPS: readonly SidebarApp[] = SIDEBAR_APP_DEFINITIONS + +const SIDEBAR_APP_BY_ID: Record = SIDEBAR_APPS.reduce( (acc, app) => { acc[app.id] = app return acc }, - {} as Record + {} as Record ) -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(SIDEBAR_FAVORITE_ORDER) +const sidebarFavoriteSet = new Set(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() +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() + 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() + 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() + 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() + 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() + 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 } diff --git a/src/shared/data/preference/preferenceSchemas.ts b/src/shared/data/preference/preferenceSchemas.ts index a8a8bdc592..d23a6e1f1d 100644 --- a/src/shared/data/preference/preferenceSchemas.ts +++ b/src/shared/data/preference/preferenceSchemas.ts @@ -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 diff --git a/src/shared/data/preference/preferenceTypes.ts b/src/shared/data/preference/preferenceTypes.ts index 63964093fe..fdf3d835f0 100644 --- a/src/shared/data/preference/preferenceTypes.ts +++ b/src/shared/data/preference/preferenceTypes.ts @@ -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' diff --git a/tests/renderer.setup.ts b/tests/renderer.setup.ts index 777f1872f0..df9fc96b33 100644 --- a/tests/renderer.setup.ts +++ b/tests/renderer.setup.ts @@ -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 } diff --git a/v2-refactor-temp/tools/data-classify/data/target-key-definitions.json b/v2-refactor-temp/tools/data-classify/data/target-key-definitions.json index 762b70f095..77615d422d 100644 --- a/v2-refactor-temp/tools/data-classify/data/target-key-definitions.json +++ b/v2-refactor-temp/tools/data-classify/data/target-key-definitions.json @@ -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",