From 1fcf779f2a33f0d76bd048f2dc18f111d823cce1 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sat, 4 Jul 2026 00:36:43 +0800 Subject: [PATCH 01/40] refactor: update session and topic display modes in AgentPage and HomePage - Changed session layout preference from 'agent.layout' to 'agent.session.display_mode' in AgentPage. - Updated related tests to reflect the new session display mode. - Refactored Sessions component to remove unused session display options and history button. - Adjusted HomePage to use 'topic.tab.display_mode' instead of 'topic.layout' for topic display preferences. - Removed manual chat layout switches from CommonSettings. - Updated tests across various components to ensure consistency with the new display mode preferences. --- .../chat/resources/SessionListOptionsMenu.tsx | 94 ++++++++++++++ .../chat/resources/TopicListOptionsMenu.tsx | 68 +++++++++++ .../components/chat/resources/index.ts | 2 + .../resources/variants/AgentResourceList.tsx | 11 +- .../variants/AssistantResourceList.tsx | 10 +- .../resources/variants/ResourceEntityRail.tsx | 25 ++-- .../EntityResourceListActions.test.tsx | 115 +++++++++++++++++- src/renderer/pages/agents/AgentPage.tsx | 4 +- .../pages/agents/__tests__/AgentPage.test.tsx | 56 +++++---- .../pages/agents/components/Sessions.tsx | 112 +---------------- .../components/__tests__/Sessions.test.tsx | 8 +- src/renderer/pages/home/HomePage.tsx | 16 ++- .../pages/home/Tabs/components/Topics.tsx | 41 ++++--- .../Tabs/components/__tests__/Topics.test.tsx | 41 +++++-- .../pages/home/__tests__/HomePage.test.tsx | 57 +++++---- .../__tests__/CommonSettings.test.tsx | 12 ++ .../pages/settings/CommonSettings/index.tsx | 32 +---- 17 files changed, 472 insertions(+), 232 deletions(-) create mode 100644 src/renderer/components/chat/resources/SessionListOptionsMenu.tsx create mode 100644 src/renderer/components/chat/resources/TopicListOptionsMenu.tsx diff --git a/src/renderer/components/chat/resources/SessionListOptionsMenu.tsx b/src/renderer/components/chat/resources/SessionListOptionsMenu.tsx new file mode 100644 index 00000000000..9491d4fb0da --- /dev/null +++ b/src/renderer/components/chat/resources/SessionListOptionsMenu.tsx @@ -0,0 +1,94 @@ +import { MenuDivider, MenuItem, MenuList, Popover, PopoverContent, PopoverTrigger } from '@cherrystudio/ui' +import type { AgentSessionDisplayMode } from '@shared/data/preference/preferenceTypes' +import { Bot, ChevronsDownUp, ChevronsUpDown, Clock, Folder, History, ListFilter } from 'lucide-react' +import { type ReactNode, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { ResourceList } from './ResourceList' + +const SESSION_DISPLAY_OPTIONS: AgentSessionDisplayMode[] = ['time', 'workdir', 'agent'] +export const SESSION_DISPLAY_LABEL_KEYS: Record = { + agent: 'agent.session.display.agent', + time: 'agent.session.display.time', + workdir: 'agent.session.display.workdir' +} +const SESSION_DISPLAY_ICONS: Record = { + agent: , + time: , + workdir: +} + +type SessionListOptionsMenuProps = { + mode: AgentSessionDisplayMode + onChange: (mode: AgentSessionDisplayMode) => void + onOpenHistoryRecords?: () => void + sectionId?: string +} + +export function SessionListOptionsMenu({ + mode, + onChange, + onOpenHistoryRecords, + sectionId +}: SessionListOptionsMenuProps) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + + return ( + + + + + + + + +
+ {t('agent.session.display.title')} +
+ {SESSION_DISPLAY_OPTIONS.map((option) => ( + { + onChange(option) + setOpen(false) + }} + /> + ))} + {sectionId && ( + <> + + } + collapseIcon={} + sectionId={sectionId} + expandLabel={t('agent.session.group.expand_all')} + collapseLabel={t('agent.session.group.collapse_all')} + onClick={() => { + setOpen(false) + }} + /> + + )} + {onOpenHistoryRecords && } + {onOpenHistoryRecords && ( + } + label={t('history.records.shortTitle')} + onClick={() => { + onOpenHistoryRecords() + setOpen(false) + }} + /> + )} +
+
+
+ ) +} diff --git a/src/renderer/components/chat/resources/TopicListOptionsMenu.tsx b/src/renderer/components/chat/resources/TopicListOptionsMenu.tsx new file mode 100644 index 00000000000..07f609f6baa --- /dev/null +++ b/src/renderer/components/chat/resources/TopicListOptionsMenu.tsx @@ -0,0 +1,68 @@ +import { MenuDivider, MenuItem, MenuList, Popover, PopoverContent, PopoverTrigger } from '@cherrystudio/ui' +import type { TopicDisplayMode } from '@shared/data/preference/preferenceTypes' +import { Bot, Clock, History, ListFilter } from 'lucide-react' +import { type ReactNode, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { ResourceList } from './ResourceList' + +const TOPIC_DISPLAY_OPTIONS: TopicDisplayMode[] = ['time', 'assistant'] +const TOPIC_DISPLAY_LABEL_KEYS: Record = { + assistant: 'chat.topics.display.assistant', + time: 'chat.topics.display.time' +} +const TOPIC_DISPLAY_ICONS: Record = { + assistant: , + time: +} + +type TopicListOptionsMenuProps = { + mode: TopicDisplayMode + onChange: (mode: TopicDisplayMode) => void + onOpenHistoryRecords?: () => void +} + +export function TopicListOptionsMenu({ mode, onChange, onOpenHistoryRecords }: TopicListOptionsMenuProps) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + + return ( + + + + + + + + +
{t('chat.topics.display.title')}
+ {TOPIC_DISPLAY_OPTIONS.map((option) => ( + { + onChange(option) + setOpen(false) + }} + /> + ))} + {onOpenHistoryRecords && } + {onOpenHistoryRecords && ( + } + label={t('history.records.shortTitle')} + onClick={() => { + onOpenHistoryRecords() + setOpen(false) + }} + /> + )} +
+
+
+ ) +} diff --git a/src/renderer/components/chat/resources/index.ts b/src/renderer/components/chat/resources/index.ts index 419211547e5..6ac67c738df 100644 --- a/src/renderer/components/chat/resources/index.ts +++ b/src/renderer/components/chat/resources/index.ts @@ -55,6 +55,8 @@ export { moveResourceListStringGroupAfterDrop, withResourceListGroupIdPrefix } from './resourceListReorder' +export { SESSION_DISPLAY_LABEL_KEYS, SessionListOptionsMenu } from './SessionListOptionsMenu' +export { TopicListOptionsMenu } from './TopicListOptionsMenu' export type { UseResourceListPinnedStateOptions, UseResourceListPinnedStateResult } from './useResourceListPinnedState' export { useResourceListPinnedState } from './useResourceListPinnedState' export { SessionResourceList, TopicResourceList } from './variants' diff --git a/src/renderer/components/chat/resources/variants/AgentResourceList.tsx b/src/renderer/components/chat/resources/variants/AgentResourceList.tsx index f9e105e0531..18f50584758 100644 --- a/src/renderer/components/chat/resources/variants/AgentResourceList.tsx +++ b/src/renderer/components/chat/resources/variants/AgentResourceList.tsx @@ -1,3 +1,4 @@ +import { usePreference } from '@data/hooks/usePreference' import { loggerService } from '@logger' import type { ResolvedAction } from '@renderer/components/chat/actions/actionTypes' import EmojiIcon from '@renderer/components/EmojiIcon' @@ -14,6 +15,7 @@ import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import type { ConversationResourceMenuItem } from '../ConversationResourceMenu' +import { SessionListOptionsMenu } from '../SessionListOptionsMenu' import { ResourceEntityRail, type ResourceEntityRailItem } from './ResourceEntityRail' import { sortResourceItemsByPinnedTime } from './resourceEntitySort' import { type ResourceEntityRailReorderAnchor, useResourceEntityRail } from './useResourceEntityRail' @@ -55,6 +57,7 @@ export function AgentResourceList({ onActiveAgentDeleted }: AgentResourceListProps) { const { t } = useTranslation() + const [sessionDisplayMode, setSessionDisplayMode] = usePreference('agent.session.display_mode') const { agents, isLoading: isAgentsLoading, error: agentsError, refetch: refetchAgents } = useAgents() const { sessions, @@ -265,7 +268,13 @@ export function AgentResourceList({ addIcon={} addLabel={t('agent.add.title')} onAdd={onAddAgent ?? (() => onStartMissingAgentDraft?.())} - onOpenHistoryRecords={onOpenHistoryRecords} + headerActions={ + void setSessionDisplayMode(nextMode)} + onOpenHistoryRecords={onOpenHistoryRecords} + /> + } resourceMenuItems={resourceMenuItems} onSelect={handleSelect} onReorder={handleReorder} diff --git a/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx b/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx index 4962a61949f..c0a11ce1141 100644 --- a/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx +++ b/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx @@ -15,6 +15,7 @@ import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import type { ConversationResourceMenuItem } from '../ConversationResourceMenu' +import { TopicListOptionsMenu } from '../TopicListOptionsMenu' import { ResourceEntityRail, type ResourceEntityRailItem } from './ResourceEntityRail' import { sortResourceItemsByPinnedTime } from './resourceEntitySort' import { type ResourceEntityRailReorderAnchor, useResourceEntityRail } from './useResourceEntityRail' @@ -52,6 +53,7 @@ export function AssistantResourceList({ }: AssistantResourceListProps) { const { t } = useTranslation() const [assistantSortType, setAssistantSortType] = usePreference('assistant.tab.sort_type') + const [topicDisplayMode, setTopicDisplayMode] = usePreference('topic.tab.display_mode') const isTagGrouping = assistantSortType === 'tags' const { assistants, @@ -287,7 +289,13 @@ export function AssistantResourceList({ addIcon={} addLabel={t('chat.add.assistant.title')} onAdd={onAddAssistant ?? (() => onStartDraftAssistant(null))} - onOpenHistoryRecords={onOpenHistoryRecords} + headerActions={ + void setTopicDisplayMode(nextMode)} + onOpenHistoryRecords={onOpenHistoryRecords} + /> + } resourceMenuItems={resourceMenuItems} onSelect={handleSelect} onReorder={handleReorder} diff --git a/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx b/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx index b3363034e22..fca85599ddf 100644 --- a/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx +++ b/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx @@ -76,6 +76,7 @@ export type ResourceEntityRailProps readonly ResolvedAction[] + headerActions?: ReactNode listRef?: RefObject onAdd: () => void | Promise /** When provided, a history-records button sits next to the add button. */ @@ -117,6 +118,7 @@ export function ResourceEntityRail void onAdd()} actions={ - onOpenHistoryRecords ? ( - - onOpenHistoryRecords()}> - - - + headerActions || onOpenHistoryRecords ? ( + <> + {headerActions} + {onOpenHistoryRecords && ( + + onOpenHistoryRecords()}> + + + + )} + ) : undefined } /> diff --git a/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx b/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx index 416766472bc..6a6c980ff81 100644 --- a/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx +++ b/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx @@ -1,6 +1,7 @@ import type { ResolvedAction } from '@renderer/components/chat/actions/actionTypes' import type { ResourceEntityRailItem } from '@renderer/components/chat/resources/variants/ResourceEntityRail' import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AgentResourceList } from '../AgentResourceList' @@ -18,8 +19,29 @@ const agentDataMocks = vi.hoisted(() => ({ })) const preferenceMocks = vi.hoisted(() => ({ + setPreference: vi.fn(), sortType: 'list' as 'list' | 'tags', - setSortType: vi.fn() + setSortType: vi.fn(), + values: new Map() +})) + +vi.mock('@cherrystudio/ui', () => ({ + Button: ({ children, onClick, ...props }: { children?: ReactNode; onClick?: () => void }) => ( + + ), + MenuItem: ({ icon, label, onClick }: { icon?: ReactNode; label: ReactNode; onClick?: () => void }) => ( + + ), + MenuDivider: () =>
, + MenuList: ({ children }: { children?: ReactNode }) =>
{children}
, + Popover: ({ children }: { children?: ReactNode }) =>
{children}
, + PopoverContent: ({ children }: { children?: ReactNode }) =>
{children}
, + PopoverTrigger: ({ children }: { children?: ReactNode }) => <>{children} })) vi.mock('react-i18next', () => ({ @@ -29,7 +51,29 @@ vi.mock('react-i18next', () => ({ })) vi.mock('@data/hooks/usePreference', () => ({ - usePreference: () => [preferenceMocks.sortType, preferenceMocks.setSortType] + usePreference: (key: string) => { + if (key === 'assistant.tab.sort_type') { + return [ + preferenceMocks.sortType, + (value: unknown) => { + preferenceMocks.sortType = value as 'list' | 'tags' + preferenceMocks.setSortType(value) + preferenceMocks.setPreference(key, value) + } + ] + } + + const defaultValue = + key === 'topic.tab.display_mode' ? 'assistant' : key === 'agent.session.display_mode' ? 'agent' : undefined + + return [ + preferenceMocks.values.get(key) ?? defaultValue, + (value: unknown) => { + preferenceMocks.values.set(key, value) + preferenceMocks.setPreference(key, value) + } + ] + } })) vi.mock('@logger', () => ({ @@ -63,14 +107,17 @@ vi.mock('@renderer/components/chat/resources/variants/useResourceEntityRail', () vi.mock('@renderer/components/chat/resources/variants/ResourceEntityRail', () => ({ ResourceEntityRail: ({ getContextMenuActions, + headerActions, items, onContextMenuAction }: { getContextMenuActions?: (item: ResourceEntityRailItem) => readonly ResolvedAction[] + headerActions?: ReactNode items: readonly ResourceEntityRailItem[] onContextMenuAction?: (item: ResourceEntityRailItem, action: ResolvedAction) => void | Promise }) => (
+ {headerActions} {items.map((item) => { const actions = getContextMenuActions?.(item) ?? [] @@ -202,6 +249,8 @@ vi.mock('@renderer/utils/error', () => ({ describe('classic layout entity resource list actions', () => { beforeEach(() => { preferenceMocks.sortType = 'list' + preferenceMocks.values.clear() + preferenceMocks.setPreference.mockClear() preferenceMocks.setSortType.mockClear() assistantDataMocks.deleteAssistant.mockResolvedValue(undefined) assistantDataMocks.refreshTopics.mockResolvedValue(undefined) @@ -277,6 +326,33 @@ describe('classic layout entity resource list actions', () => { expect(preferenceMocks.setSortType).toHaveBeenCalledWith('list') }) + it('lets the classic assistant rail switch back to the time topic view', () => { + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'chat.topics.display.time' })) + + expect(preferenceMocks.setPreference).toHaveBeenCalledWith('topic.tab.display_mode', 'time') + }) + + it('keeps classic assistant rail history in the shared display menu', () => { + const onOpenHistoryRecords = vi.fn() + + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'history.records.shortTitle' })) + + expect(onOpenHistoryRecords).toHaveBeenCalledTimes(1) + }) + it('uses delete-agent actions for the classic layout agent context and more menus', async () => { const onStartMissingAgentDraft = vi.fn() const onActiveAgentDeleted = vi.fn() @@ -311,4 +387,39 @@ describe('classic layout entity resource list actions', () => { await waitFor(() => expect(onActiveAgentDeleted).toHaveBeenCalledWith('agent-1')) expect(onStartMissingAgentDraft).not.toHaveBeenCalled() }) + + it('lets the classic agent rail switch back to the workdir session view', () => { + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'agent.session.display.workdir' })) + + expect(preferenceMocks.setPreference).toHaveBeenCalledWith('agent.session.display_mode', 'workdir') + }) + + it('keeps classic agent rail history in the shared display menu without section toggles', () => { + const onOpenHistoryRecords = vi.fn() + + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'history.records.shortTitle' })) + + expect(onOpenHistoryRecords).toHaveBeenCalledTimes(1) + expect(screen.queryByText('agent.session.group.expand_all')).not.toBeInTheDocument() + expect(screen.queryByText('agent.session.group.collapse_all')).not.toBeInTheDocument() + }) }) diff --git a/src/renderer/pages/agents/AgentPage.tsx b/src/renderer/pages/agents/AgentPage.tsx index 55c628ed908..4b6e5ca405a 100644 --- a/src/renderer/pages/agents/AgentPage.tsx +++ b/src/renderer/pages/agents/AgentPage.tsx @@ -85,8 +85,8 @@ function findReusableEmptySession { const [showSidebar, setShowSidebar] = usePreference('topic.tab.show') - const [sessionLayout] = usePreference('agent.layout') - const isClassicSessionLayout = sessionLayout === 'classic' + const [sessionDisplayMode] = usePreference('agent.session.display_mode') + const isClassicSessionLayout = sessionDisplayMode === 'agent' // Classic layout shares this full-sessions source with the rail; modern layout leaves it disabled (no fetch). // The picker uses it to reuse an empty placeholder session instead of stacking new ones. const { diff --git a/src/renderer/pages/agents/__tests__/AgentPage.test.tsx b/src/renderer/pages/agents/__tests__/AgentPage.test.tsx index 975105b3359..17fd7a0206c 100644 --- a/src/renderer/pages/agents/__tests__/AgentPage.test.tsx +++ b/src/renderer/pages/agents/__tests__/AgentPage.test.tsx @@ -60,7 +60,7 @@ const agentPageMocks = vi.hoisted(() => ({ setLastUsedWorkspaceId: vi.fn(), setClassicLayoutRightPaneOpen: vi.fn(), setShowSidebar: vi.fn(), - sessionLayout: 'modern' as 'modern' | 'classic', + sessionDisplayMode: 'time' as 'time' | 'workdir' | 'agent', isActiveTab: false, showSidebar: false, routeSearch: { sessionId: 'session-initial' } as Record, @@ -115,8 +115,8 @@ vi.mock('@data/hooks/usePreference', async () => { const [value, setValue] = React.useState( key === 'topic.tab.show' ? agentPageMocks.showSidebar - : key === 'agent.layout' - ? agentPageMocks.sessionLayout + : key === 'agent.session.display_mode' + ? agentPageMocks.sessionDisplayMode : undefined ) const setPreference = vi.fn(async (nextValue: unknown) => { @@ -583,7 +583,7 @@ describe('AgentPage', () => { agentPageMocks.classicLayoutRightPaneOpen = true agentPageMocks.activeSessionOptions = null agentPageMocks.focusExistingTab.mockReturnValue(false) - agentPageMocks.sessionLayout = 'modern' + agentPageMocks.sessionDisplayMode = 'time' agentPageMocks.showSidebar = false agentPageMocks.isActiveTab = false agentPageMocks.dataApiGet.mockImplementation(async (path: string) => { @@ -613,7 +613,7 @@ describe('AgentPage', () => { }) it('renders the agent resource list in the left pane', () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' activeSessionMocks.session = { ...agentPageMocks.persistedSession, agentId: 'agent-a' } activeSessionMocks.sessionSource = 'query' @@ -626,6 +626,18 @@ describe('AgentPage', () => { expect(screen.queryByTestId('agent-side-panel')).not.toBeInTheDocument() }) + it('renders the modern session sidebar when session display mode is time', () => { + agentPageMocks.sessionDisplayMode = 'time' + activeSessionMocks.session = { ...agentPageMocks.persistedSession, agentId: 'agent-a' } + activeSessionMocks.sessionSource = 'query' + + render() + + expect(screen.getByTestId('agent-side-panel')).toBeInTheDocument() + expect(screen.queryByTestId('agent-resource-list')).not.toBeInTheDocument() + expect(screen.queryByTestId('session-resource-panel')).not.toBeInTheDocument() + }) + it('renders the agent resource view outside AgentChat runtime', () => { activeSessionMocks.session = { ...agentPageMocks.persistedSession, agentId: 'agent-a' } activeSessionMocks.sessionSource = 'query' @@ -639,7 +651,7 @@ describe('AgentPage', () => { }) it('keeps the agent resource view open while opening the classic-layout agent picker', () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' activeSessionMocks.session = { ...agentPageMocks.persistedSession, agentId: 'agent-a' } activeSessionMocks.sessionSource = 'query' @@ -654,7 +666,7 @@ describe('AgentPage', () => { }) it('keeps the agent resource view open until the selected agent session is ready', async () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' agentPageMocks.routeSearch = {} agentPageMocks.agents = [ { id: 'agent-a', model: 'model-a', name: 'Agent A' }, @@ -729,7 +741,7 @@ describe('AgentPage', () => { }) it('restores and records the classic-layout agent right pane open state from cache', () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' agentPageMocks.classicLayoutRightPaneOpen = false activeSessionMocks.session = { ...agentPageMocks.persistedSession, agentId: 'agent-a' } activeSessionMocks.sessionSource = 'query' @@ -744,7 +756,7 @@ describe('AgentPage', () => { }) it('passes the current agent task count to the classic-layout top button', () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' activeSessionMocks.session = { ...agentPageMocks.persistedSession, agentId: 'agent-a' } activeSessionMocks.sessionSource = 'query' agentPageMocks.classicLayoutSessions = [ @@ -761,7 +773,7 @@ describe('AgentPage', () => { }) it('selects the latest historical session by default when entering classic layout without a route session', async () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' agentPageMocks.routeSearch = {} agentPageMocks.classicLayoutSessions = [ { @@ -785,7 +797,7 @@ describe('AgentPage', () => { }) it('selects the latest remaining session after deleting the active agent (classic layout, never draft)', async () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' // Pin the active session via the route so the load-time auto-select effect stays out of the way. agentPageMocks.routeSearch = { sessionId: 'session-a' } activeSessionMocks.session = { ...agentPageMocks.persistedSession, id: 'session-a', agentId: 'agent-a' } @@ -821,7 +833,7 @@ describe('AgentPage', () => { }) it('creates and activates an empty session after selecting an agent from the classic-layout picker', async () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' agentPageMocks.routeSearch = {} agentPageMocks.agents = [ { id: 'agent-a', model: 'model-a', name: 'Agent A' }, @@ -859,7 +871,7 @@ describe('AgentPage', () => { }) it('uses the remembered workspace when creating an empty session from the classic-layout picker', async () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' agentPageMocks.routeSearch = {} agentPageMocks.lastUsedWorkspaceId = 'workspace-remembered' agentPageMocks.agents = [ @@ -893,7 +905,7 @@ describe('AgentPage', () => { }) it('reuses the agent latest empty session instead of creating another one from the classic-layout picker', async () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' agentPageMocks.routeSearch = {} agentPageMocks.agents = [ { id: 'agent-a', model: 'model-a', name: 'Agent A' }, @@ -929,7 +941,7 @@ describe('AgentPage', () => { }) it('reuses the latest empty session when an older candidate has an invalid timestamp', async () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' agentPageMocks.routeSearch = {} agentPageMocks.agents = [ { id: 'agent-a', model: 'model-a', name: 'Agent A' }, @@ -964,7 +976,7 @@ describe('AgentPage', () => { }) it('reuses the current agent empty session from the classic-layout composer button', async () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' activeSessionMocks.session = { ...agentPageMocks.persistedSession, id: 'session-active', @@ -995,7 +1007,7 @@ describe('AgentPage', () => { }) it('does not reuse an empty session from a different workspace from the classic-layout composer button', async () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' activeSessionMocks.session = { ...agentPageMocks.persistedSession, id: 'session-active', @@ -1042,7 +1054,7 @@ describe('AgentPage', () => { }) it('creates a fresh session from the classic-layout composer button when the latest is chatted-in with a blank name (auto-naming off)', async () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' activeSessionMocks.session = { ...agentPageMocks.persistedSession, id: 'session-active', @@ -1096,7 +1108,7 @@ describe('AgentPage', () => { }) it('toasts when the classic-layout composer empty-session creation fails', async () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' activeSessionMocks.session = { ...agentPageMocks.persistedSession, id: 'session-active', @@ -1121,7 +1133,7 @@ describe('AgentPage', () => { }) it('updates the active classic-layout session workspace through the composer control', async () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' activeSessionMocks.session = { ...agentPageMocks.persistedSession, id: 'session-active', @@ -1151,7 +1163,7 @@ describe('AgentPage', () => { }) it('creates a new session when the agent latest session is not empty from the classic-layout picker', async () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' agentPageMocks.routeSearch = {} agentPageMocks.agents = [ { id: 'agent-a', model: 'model-a', name: 'Agent A' }, @@ -1275,7 +1287,7 @@ describe('AgentPage', () => { }) it('opens the session pane when a global-search locate targets a session in the current tab', () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' agentPageMocks.classicLayoutRightPaneOpen = false agentPageMocks.focusExistingTab.mockReturnValue(false) diff --git a/src/renderer/pages/agents/components/Sessions.tsx b/src/renderer/pages/agents/components/Sessions.tsx index fddaef29c09..1303c7710cf 100644 --- a/src/renderer/pages/agents/components/Sessions.tsx +++ b/src/renderer/pages/agents/components/Sessions.tsx @@ -1,13 +1,4 @@ -import { - Button, - MenuDivider, - MenuItem, - MenuList, - Popover, - PopoverContent, - PopoverTrigger, - Tooltip -} from '@cherrystudio/ui' +import { Button, Tooltip } from '@cherrystudio/ui' import { loggerService } from '@logger' import { actionsToCommandMenuExtraItems } from '@renderer/components/chat/actions/actionMenuItems' import { @@ -21,6 +12,8 @@ import { type ResourceListReorderPayload, type ResourceListRevealRequest, type ResourceListSection, + SESSION_DISPLAY_LABEL_KEYS, + SessionListOptionsMenu, SessionResourceList } from '@renderer/components/chat/resources' import { CommandPopupMenu } from '@renderer/components/command' @@ -45,19 +38,8 @@ import { type AgentSessionWorkspaceSource, type AgentWorkspaceEntity } from '@shared/data/api/schemas/agentWorkspaces' -import { - Bot, - ChevronsDownUp, - ChevronsUpDown, - Clock, - Folder, - FolderOpen, - History, - ListFilter, - MoreHorizontal, - SquarePen -} from 'lucide-react' -import { memo, type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Folder, FolderOpen, MoreHorizontal, SquarePen } from 'lucide-react' +import { memo, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import type { DraftAgentSessionDefaults } from '../types' @@ -115,17 +97,6 @@ type SessionsProps = ControlledSessionsProps const logger = loggerService.withContext('AgentSessions') -const SESSION_DISPLAY_OPTIONS: AgentSessionDisplayMode[] = ['time', 'workdir'] -const SESSION_DISPLAY_LABEL_KEYS: Record = { - agent: 'agent.session.display.agent', - time: 'agent.session.display.time', - workdir: 'agent.session.display.workdir' -} -const SESSION_DISPLAY_ICONS: Record = { - agent: , - time: , - workdir: -} const EMPTY_WORKSPACE_ROWS: AgentWorkspaceEntity[] = [] type CreateSessionSeed = { agentId: string @@ -133,79 +104,6 @@ type CreateSessionSeed = { workspacePath?: string } -function SessionListOptionsMenu({ - mode, - onChange, - onOpenHistoryRecords, - sectionId -}: { - mode: AgentSessionDisplayMode - onChange: (mode: AgentSessionDisplayMode) => void - onOpenHistoryRecords?: () => void - sectionId?: string -}) { - const { t } = useTranslation() - const [open, setOpen] = useState(false) - - return ( - - - - - - - - -
- {t('agent.session.display.title')} -
- {SESSION_DISPLAY_OPTIONS.map((option) => ( - { - onChange(option) - setOpen(false) - }} - /> - ))} - {sectionId && ( - <> - - } - collapseIcon={} - sectionId={sectionId} - expandLabel={t('agent.session.group.expand_all')} - collapseLabel={t('agent.session.group.collapse_all')} - onClick={() => { - setOpen(false) - }} - /> - - )} - {onOpenHistoryRecords && } - {onOpenHistoryRecords && ( - } - label={t('history.records.shortTitle')} - onClick={() => { - onOpenHistoryRecords() - setOpen(false) - }} - /> - )} -
-
-
- ) -} - function AgentGroupMoreMenu({ agentId, deleteSessionsDisabled, diff --git a/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx b/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx index d74a3987303..3c0bb52d189 100644 --- a/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx +++ b/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx @@ -1702,10 +1702,12 @@ describe('Sessions', () => { const displayModeContent = openSessionListOptions() expect(within(displayModeContent as HTMLElement).getByRole('button', { name: 'Time' })).toBeInTheDocument() - expect(within(displayModeContent as HTMLElement).queryByRole('button', { name: 'Agent' })).not.toBeInTheDocument() - fireEvent.click(within(displayModeContent as HTMLElement).getByRole('button', { name: 'Work directory' })) + expect( + within(displayModeContent as HTMLElement).getByRole('button', { name: 'Work directory' }) + ).toBeInTheDocument() + fireEvent.click(within(displayModeContent as HTMLElement).getByRole('button', { name: 'Agent' })) - expect(preferenceMocks.setPreference).toHaveBeenCalledWith('agent.session.display_mode', 'workdir') + expect(preferenceMocks.setPreference).toHaveBeenCalledWith('agent.session.display_mode', 'agent') }) it('blocks cross-workspace groups from drag start while preserving same-workspace reorder', async () => { diff --git a/src/renderer/pages/home/HomePage.tsx b/src/renderer/pages/home/HomePage.tsx index abf94a5be40..8b74894f9a4 100644 --- a/src/renderer/pages/home/HomePage.tsx +++ b/src/renderer/pages/home/HomePage.tsx @@ -125,10 +125,11 @@ const HomePage: FC = () => { const lastRecordedRecentTopicRef = useRef(undefined) const [pendingLocateMessageId, setPendingLocateMessageId] = useState() const [showSidebar, setShowSidebar] = usePreference('topic.tab.show') - const [topicLayout] = usePreference('topic.layout') - const isClassicTopicLayout = topicLayout === 'classic' + const [topicDisplayMode] = usePreference('topic.tab.display_mode') + const isClassicTopicLayout = topicDisplayMode === 'assistant' // Classic-layout right-pane open state, cached on the assistant surface's own key. const [topicPaneOpen, setTopicPaneOpen] = useClassicLayoutRightPaneOpen('chat', isClassicTopicLayout) + const hasAutoOpenedClassicTopicPaneRef = useRef(false) // Classic layout shares this full-topics source with the rail; modern layout leaves it disabled (no fetch). // The picker uses it to reuse an empty placeholder topic instead of stacking new ones. const { @@ -141,6 +142,17 @@ const HomePage: FC = () => { const [historyRecordsOpen, setHistoryRecordsOpen] = useState(false) const [assistantPickerOpen, setAssistantPickerOpen] = useState(false) + useEffect(() => { + if (!isClassicTopicLayout) { + hasAutoOpenedClassicTopicPaneRef.current = false + return + } + + if (hasAutoOpenedClassicTopicPaneRef.current) return + hasAutoOpenedClassicTopicPaneRef.current = true + setTopicPaneOpen(true) + }, [isClassicTopicLayout, setTopicPaneOpen]) + const location = useLocation() const routeSearch = parseChatRouteSearch(useSearch({ strict: false }) as Record) const currentTab = useCurrentTab() diff --git a/src/renderer/pages/home/Tabs/components/Topics.tsx b/src/renderer/pages/home/Tabs/components/Topics.tsx index 85e317caf5a..aa87c533685 100644 --- a/src/renderer/pages/home/Tabs/components/Topics.tsx +++ b/src/renderer/pages/home/Tabs/components/Topics.tsx @@ -15,6 +15,7 @@ import { type ResourceListReorderPayload, type ResourceListRevealRequest, type ResourceListSection, + TopicListOptionsMenu, TopicResourceList, useResourceListActions, useResourceListPinnedState, @@ -46,7 +47,7 @@ import { cn } from '@renderer/utils/style' import { DEFAULT_ASSISTANT_EMOJI } from '@shared/data/presets/defaultAssistant' import dayjs from 'dayjs' import { findIndex } from 'es-toolkit/compat' -import { Bot, History, MoreHorizontal, PinIcon, SquarePen, Trash2, XIcon } from 'lucide-react' +import { Bot, MoreHorizontal, PinIcon, SquarePen, Trash2, XIcon } from 'lucide-react' import type { MouseEvent, RefObject } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { useTranslation } from 'react-i18next' @@ -138,23 +139,6 @@ function resolveAssistantIdForTopicGroup( return assistantId } -function TopicHistoryButton({ onOpenHistoryRecords }: { onOpenHistoryRecords?: () => void }) { - const { t } = useTranslation() - - if (!onOpenHistoryRecords) return null - - return ( - - - - - - ) -} - function AssistantGroupMoreMenu({ assistantId, deleteTopicsDisabled, @@ -222,7 +206,7 @@ export function Topics({ deleteTopicsByAssistantId, refreshTopics } = useTopicMutations() - const [topicDisplayMode] = usePreference('topic.tab.display_mode') + const [topicDisplayMode, setTopicDisplayMode] = usePreference('topic.tab.display_mode') const [topicExpansionTime, setTopicExpansionTime] = usePersistCache('ui.topic.expansion.time') const [topicExpansionAssistant, setTopicExpansionAssistant] = usePersistCache('ui.topic.expansion.assistant') const [renamingTopics] = useCache('topic.renaming') @@ -899,6 +883,15 @@ export function Topics({ }, [isAssistantDisplayMode, isRightPanel, setTopicExpansionAssistant, setTopicExpansionTime] ) + const handleTopicDisplayModeChange = useCallback( + (nextMode: TopicDisplayMode) => { + if (nextMode === 'assistant') { + setTopicExpansionAssistant([]) + } + void setTopicDisplayMode(nextMode) + }, + [setTopicDisplayMode, setTopicExpansionAssistant] + ) const canDragTopicItem = useCallback( ({ item }: { item: Topic }) => isAssistantDisplayMode && !item.pinned, [isAssistantDisplayMode] @@ -1089,7 +1082,15 @@ export function Topics({ icon={} label={t('chat.conversation.new')} onClick={() => void onNewTopic?.(headerCreateTopicPayload)} - actions={} + actions={ + <> + + + } /> diff --git a/src/renderer/pages/home/Tabs/components/__tests__/Topics.test.tsx b/src/renderer/pages/home/Tabs/components/__tests__/Topics.test.tsx index 4bf4a15ba87..7cc6eefdfa1 100644 --- a/src/renderer/pages/home/Tabs/components/__tests__/Topics.test.tsx +++ b/src/renderer/pages/home/Tabs/components/__tests__/Topics.test.tsx @@ -1473,7 +1473,6 @@ describe('Topics', () => { // The assistant header exposes a direct history button and no bulk-action menu. expect(screen.getByRole('button', { name: 'History' })).toBeInTheDocument() - expect(screen.queryByText('Display mode')).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Collapse all' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Expand all' })).not.toBeInTheDocument() }) @@ -1602,24 +1601,46 @@ describe('Topics', () => { expect(screen.getByRole('button', { name: 'Pinned' })).toHaveAttribute('aria-expanded', 'true') }) - it('renders the topic header history action without display mode controls', () => { + it('renders the topic header display mode and history actions in the shared menu', () => { const { onOpenHistoryRecords } = renderTopicList() expect(screen.getByTestId('resource-list-topic')).toBeInTheDocument() expect(screen.queryByPlaceholderText('Search conversations')).not.toBeInTheDocument() expect(screen.queryByLabelText('Manage topics')).not.toBeInTheDocument() - expect(screen.queryByLabelText('Display mode')).not.toBeInTheDocument() + fireEvent.click(screen.getByLabelText('Display mode')) + expect(screen.getByText('Display mode')).toBeInTheDocument() + const displayModeMenu = screen.getByText('Display mode').closest('[data-testid="menu-list"]') + expect(displayModeMenu).not.toBeNull() + expect(within(displayModeMenu as HTMLElement).getByRole('button', { name: 'Time' })).toBeInTheDocument() + expect(within(displayModeMenu as HTMLElement).getByRole('button', { name: 'Assistant' })).toBeInTheDocument() + expect(within(displayModeMenu as HTMLElement).getByRole('button', { name: 'History' })).toBeInTheDocument() + + fireEvent.click(within(displayModeMenu as HTMLElement).getByRole('button', { name: 'Time' })) + expect(MockUsePreferenceUtils.getPreferenceValue('topic.tab.display_mode' as never)).toBe('time') + + fireEvent.click(within(displayModeMenu as HTMLElement).getByRole('button', { name: 'History' })) + expect(onOpenHistoryRecords).toHaveBeenCalledTimes(1) + }) + + it('expands assistant topic groups when switching to assistant display mode from the menu', () => { + MockUsePreferenceUtils.setPreferenceValue('topic.tab.display_mode' as never, 'time') + setTopicGroupExpansionCache({ + time: ['topic:time:yesterday'], + assistant: ['topic:assistant:assistant-1'] + }) + + renderTopicList() - // The assistant header shows a direct history button (no options popover / display-mode controls). - const historyButton = screen.getByRole('button', { name: 'History' }) - expect(historyButton.querySelector('svg')).not.toBeNull() - expect(historyButton).not.toHaveClass('text-[11px]') - expect(screen.queryByText('Display mode')).not.toBeInTheDocument() + fireEvent.click(screen.getByLabelText('Display mode')) + const displayModeMenu = screen.getByText('Display mode').closest('[data-testid="menu-list"]') + expect(displayModeMenu).not.toBeNull() + + fireEvent.click(within(displayModeMenu as HTMLElement).getByRole('button', { name: 'Assistant' })) - fireEvent.click(historyButton) - expect(onOpenHistoryRecords).toHaveBeenCalledTimes(1) expect(MockUsePreferenceUtils.getPreferenceValue('topic.tab.display_mode' as never)).toBe('assistant') + expect(getTopicGroupExpansionCache().assistant).toEqual([]) + expect(getTopicGroupExpansionCache().time).toEqual(['topic:time:yesterday']) }) it('keeps assistant grouped topics in the generic loading state until all pages are ready', () => { diff --git a/src/renderer/pages/home/__tests__/HomePage.test.tsx b/src/renderer/pages/home/__tests__/HomePage.test.tsx index d8735917a2b..4080e0d7507 100644 --- a/src/renderer/pages/home/__tests__/HomePage.test.tsx +++ b/src/renderer/pages/home/__tests__/HomePage.test.tsx @@ -652,7 +652,7 @@ describe('HomePage', () => { }) it('renders the assistant resource list with the resource pane open by default', () => { - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') render() @@ -664,6 +664,16 @@ describe('HomePage', () => { expect(screen.queryByTestId('home-tabs')).not.toBeInTheDocument() }) + it('renders the modern topic sidebar when topic display mode is time', () => { + homeMocks.preferenceValues.set('topic.tab.display_mode', 'time') + + render() + + expect(screen.getByTestId('home-tabs')).toBeInTheDocument() + expect(screen.queryByTestId('assistant-resource-list')).not.toBeInTheDocument() + expect(screen.getByTestId('topic-right-pane-provider')).toHaveAttribute('data-default-tab', 'branch') + }) + it('renders the assistant resource view in the chat center', () => { render() @@ -675,7 +685,7 @@ describe('HomePage', () => { }) it('keeps the assistant resource view open while opening the classic-layout assistant picker', () => { - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') render() @@ -688,7 +698,7 @@ describe('HomePage', () => { }) it('keeps the assistant resource view open until the selected assistant topic is ready', async () => { - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') let resolveCreateTopic!: (topic: Topic) => void homeMocks.createTopic.mockReturnValue( new Promise((resolve) => { @@ -752,7 +762,7 @@ describe('HomePage', () => { }) it('creates an empty classic-layout topic from the inline assistant catalog go-to-chat action', async () => { - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') homeMocks.assistants = [{ id: 'assistant-default' }, { id: 'assistant-2' }] homeMocks.createTopic.mockResolvedValue({ ...createdTopic, assistantId: 'assistant-2' }) @@ -767,13 +777,16 @@ describe('HomePage', () => { expect(screen.getByTestId('active-topic-assistant')).toHaveTextContent('assistant-2') }) - it('restores and records the classic-layout topic right pane open state from cache', () => { - homeMocks.preferenceValues.set('topic.layout', 'classic') + it('auto-opens the classic-layout topic right pane once and records manual close state', async () => { + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') homeMocks.persistCacheValues.set('ui.chat.right_pane_open', false) render() - expect(screen.getByTestId('topic-right-pane-provider')).toHaveAttribute('data-default-open', 'false') + await waitFor(() => + expect(screen.getByTestId('topic-right-pane-provider')).toHaveAttribute('data-default-open', 'true') + ) + expect(homeMocks.cacheSetPersist).toHaveBeenCalledWith('ui.chat.right_pane_open', true) fireEvent.click(screen.getByRole('button', { name: 'Close topic right pane' })) @@ -781,7 +794,7 @@ describe('HomePage', () => { }) it('passes the current assistant topic count to the classic-layout top button', () => { - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') homeMocks.classicLayoutTopics = [ { ...historyTopic, id: 'topic-a' }, { ...historyTopic, id: 'topic-b' }, @@ -797,7 +810,7 @@ describe('HomePage', () => { it('selects the latest historical topic by default when entering classic layout without a route topic', async () => { homeMocks.locationState = undefined - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') homeMocks.classicLayoutTopics = [ { ...historyTopic, id: 'topic-older', updatedAt: '2026-01-01T00:00:00.000Z' }, { ...historyTopic, id: 'topic-latest', updatedAt: '2026-01-03T00:00:00.000Z' } @@ -812,7 +825,7 @@ describe('HomePage', () => { it('selects the latest remaining topic after deleting the active assistant (classic layout, never draft)', async () => { homeMocks.locationState = undefined - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') homeMocks.classicLayoutTopics = [ { ...historyTopic, id: 'topic-a', assistantId: 'assistant-a', updatedAt: '2026-01-05T00:00:00.000Z' }, { ...historyTopic, id: 'topic-b-old', assistantId: 'assistant-b', updatedAt: '2026-01-01T00:00:00.000Z' }, @@ -833,7 +846,7 @@ describe('HomePage', () => { }) it('creates and activates an empty topic after selecting an existing assistant from the classic-layout picker', async () => { - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') homeMocks.createTopic.mockResolvedValue({ ...createdTopic, assistantId: 'assistant-2' }) render() @@ -848,7 +861,7 @@ describe('HomePage', () => { }) it('adds a catalog assistant before creating an empty topic from the classic-layout picker', async () => { - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') homeMocks.createTopic.mockResolvedValue({ ...createdTopic, assistantId: 'assistant-created' }) render() @@ -869,7 +882,7 @@ describe('HomePage', () => { }) it('reuses an existing assistant whose name matches the catalog preset instead of duplicating it', async () => { - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') homeMocks.assistants = [{ id: 'assistant-default' }, { id: 'assistant-existing', name: 'Catalog Preset' }] homeMocks.createTopic.mockResolvedValue({ ...createdTopic, assistantId: 'assistant-existing' }) @@ -884,7 +897,7 @@ describe('HomePage', () => { }) it('reuses the assistant latest empty topic instead of creating another one in the classic-layout picker', async () => { - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') homeMocks.classicLayoutTopics = [ { id: 'topic-empty-latest', @@ -913,7 +926,7 @@ describe('HomePage', () => { }) it('reuses the latest empty topic when an older candidate has an invalid timestamp', async () => { - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') homeMocks.classicLayoutTopics = [ { id: 'topic-empty-invalid', @@ -941,7 +954,7 @@ describe('HomePage', () => { }) it('reuses the current assistant empty topic from the classic-layout composer button', async () => { - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') homeMocks.assistants = [{ id: 'assistant-1' }, { id: 'assistant-default' }] homeMocks.classicLayoutTopics = [ { @@ -963,7 +976,7 @@ describe('HomePage', () => { }) it('creates and activates a fresh empty topic from the classic-layout composer button when no empty topic exists', async () => { - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') homeMocks.assistants = [{ id: 'assistant-1' }, { id: 'assistant-default' }] homeMocks.classicLayoutTopics = [ { @@ -990,7 +1003,7 @@ describe('HomePage', () => { }) it('creates a new topic when the assistant latest topic is chatted-in with a blank name (auto-naming off) in the classic-layout picker', async () => { - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') // Auto-naming off keeps the name blank, but updatedAt has moved past createdAt — this is a real // conversation that must NOT be reopened as a reusable empty placeholder (#16434). homeMocks.classicLayoutTopics = [ @@ -1013,7 +1026,7 @@ describe('HomePage', () => { }) it('ignores a rapid double-click on the classic-layout composer new-topic action', () => { - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') homeMocks.assistants = [{ id: 'assistant-1' }, { id: 'assistant-default' }] homeMocks.classicLayoutTopics = [] // Never resolves: the first create stays in flight so the re-entry guard must drop the second click. @@ -1029,7 +1042,7 @@ describe('HomePage', () => { }) it('focuses the existing tab instead of duplicating a reused topic already open elsewhere', async () => { - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') homeMocks.focusExistingTab.mockReturnValue(true) homeMocks.classicLayoutTopics = [ { @@ -1056,7 +1069,7 @@ describe('HomePage', () => { }) it('toasts and leaves the active topic untouched when classic-layout picker topic creation fails', async () => { - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') homeMocks.classicLayoutTopics = [] homeMocks.createTopic.mockRejectedValue(new Error('create failed')) const toastError = vi.fn() @@ -1073,7 +1086,7 @@ describe('HomePage', () => { }) it('toasts when the classic-layout composer empty-topic creation fails', async () => { - homeMocks.preferenceValues.set('topic.layout', 'classic') + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') homeMocks.assistants = [{ id: 'assistant-1' }, { id: 'assistant-default' }] homeMocks.classicLayoutTopics = [] homeMocks.createTopic.mockRejectedValue(new Error('create failed')) diff --git a/src/renderer/pages/settings/CommonSettings/__tests__/CommonSettings.test.tsx b/src/renderer/pages/settings/CommonSettings/__tests__/CommonSettings.test.tsx index e924ee338f4..1e03a3e36aa 100644 --- a/src/renderer/pages/settings/CommonSettings/__tests__/CommonSettings.test.tsx +++ b/src/renderer/pages/settings/CommonSettings/__tests__/CommonSettings.test.tsx @@ -297,4 +297,16 @@ describe('CommonSettings language selector', () => { expect(screen.getByRole('combobox', { name: /中文/ })).toBeInTheDocument() expect(screen.queryByRole('combobox', { name: /English/ })).not.toBeInTheDocument() }) + + it('does not render manual chat layout switches', async () => { + render() + + await waitFor(() => { + expect(window.api.getSystemFonts).toHaveBeenCalled() + expect(window.api.handleZoomFactor).toHaveBeenCalled() + }) + + expect(screen.queryByText('settings.messages.layout.conversation')).not.toBeInTheDocument() + expect(screen.queryByText('settings.messages.layout.work')).not.toBeInTheDocument() + }) }) diff --git a/src/renderer/pages/settings/CommonSettings/index.tsx b/src/renderer/pages/settings/CommonSettings/index.tsx index b5a86a96c2e..40e61b1ef63 100644 --- a/src/renderer/pages/settings/CommonSettings/index.tsx +++ b/src/renderer/pages/settings/CommonSettings/index.tsx @@ -31,7 +31,7 @@ import { formatErrorMessage } from '@renderer/utils/error' import { isLinux, isMac } from '@renderer/utils/platform' import { cn } from '@renderer/utils/style' import { isValidProxyUrl } from '@renderer/utils/url' -import type { ChatLayoutMode, LanguageVarious, MenuPresentationMode } from '@shared/data/preference/preferenceTypes' +import type { LanguageVarious, MenuPresentationMode } from '@shared/data/preference/preferenceTypes' import { ThemeMode } from '@shared/data/preference/preferenceTypes' import { defaultLanguage } from '@shared/utils/languages' import { Code, MessageSquare, Minus, Monitor, Moon, Palette, Plus, Shield, Sun } from 'lucide-react' @@ -171,8 +171,6 @@ const CommonSettings: FC = () => { const [menuPresentationMode, setMenuPresentationMode] = usePreference('menu.presentation_mode') const [customCss, setCustomCss] = usePreference('ui.custom_css') const [fontSize] = usePreference('chat.message.font_size') - const [topicLayout, setTopicLayout] = usePreference('topic.layout') - const [sessionLayout, setSessionLayout] = usePreference('agent.layout') const [useSystemTitleBar, setUseSystemTitleBar] = usePreference('app.use_system_title_bar') const [notificationSettings, setNotificationSettings] = useMultiplePreferences({ assistant: 'app.notification.assistant.enabled', @@ -334,14 +332,6 @@ const CommonSettings: FC = () => { [t] ) - const layoutOptions = useMemo( - () => [ - { value: 'classic' as const, label: t('settings.messages.layout.classic') }, - { value: 'modern' as const, label: t('settings.messages.layout.modern') } - ], - [t] - ) - const handleMenuPresentationModeChange = useCallback( (mode: MenuPresentationMode) => { confirmMenuPresentationModeChange({ @@ -642,26 +632,6 @@ const CommonSettings: FC = () => { size="sm" /> - - - {t('settings.messages.layout.conversation')} - - value={topicLayout} - onValueChange={setTopicLayout} - options={layoutOptions} - size="sm" - /> - - - - {t('settings.messages.layout.work')} - - value={sessionLayout} - onValueChange={setSessionLayout} - options={layoutOptions} - size="sm" - /> - From 0cc96e8f974a99a888157483b43a3a3eec53e5f3 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sat, 4 Jul 2026 00:45:22 +0800 Subject: [PATCH 02/40] refactor: replace legacy layout preferences with new display mode settings --- .../chat/panes/Shell/resourcePane.tsx | 2 +- .../__tests__/ChatPreferenceSections.test.tsx | 2 - .../composer/variants/AgentComposer.tsx | 26 +- .../composer/variants/ChatComposer.tsx | 15 +- .../variants/__tests__/AgentComposer.test.tsx | 2 +- .../variants/__tests__/ChatComposer.test.tsx | 2 +- .../__tests__/preferenceSchemas.test.ts | 11 +- .../data/preference/preferenceSchemas.ts | 10 +- src/shared/data/preference/preferenceTypes.ts | 3 - ...06-26-chat-layout-split-assistant-agent.md | 50 ---- .../docs/chat/chat-layout-modes.md | 258 ++---------------- .../data/target-key-definitions.json | 14 - 12 files changed, 49 insertions(+), 346 deletions(-) delete mode 100644 v2-refactor-temp/docs/breaking-changes/2026-06-26-chat-layout-split-assistant-agent.md diff --git a/src/renderer/components/chat/panes/Shell/resourcePane.tsx b/src/renderer/components/chat/panes/Shell/resourcePane.tsx index 1a540180a67..4aa6cfa14cb 100644 --- a/src/renderer/components/chat/panes/Shell/resourcePane.tsx +++ b/src/renderer/components/chat/panes/Shell/resourcePane.tsx @@ -5,7 +5,7 @@ import { createContext, type ReactNode, use, useEffect, useRef } from 'react' import { Shell, useShellActions } from './Shell' // ── Resource-list-as-right-pane wiring ────────────────────────────────────── -// In classic-layout mode (`topic.layout`/`agent.layout === 'classic'`) the topic/session list moves into the +// In classic-layout mode the topic/session list moves into the // chat's right pane as an extra tab. The list node + its label are provided once at // the page level via context, so the Chat/AgentChat tree and the pane surfaces don't prop-thread // them through every layer. The tab/panel/toggle below derive everything from this context, and diff --git a/src/renderer/components/chat/settings/__tests__/ChatPreferenceSections.test.tsx b/src/renderer/components/chat/settings/__tests__/ChatPreferenceSections.test.tsx index 6fc5ef8eae0..dcb72861d34 100644 --- a/src/renderer/components/chat/settings/__tests__/ChatPreferenceSections.test.tsx +++ b/src/renderer/components/chat/settings/__tests__/ChatPreferenceSections.test.tsx @@ -7,8 +7,6 @@ import ChatPreferenceSections from '../ChatPreferenceSections' const mocks = vi.hoisted(() => ({ setPreference: vi.fn(), preferenceValues: { - 'topic.layout': 'modern', - 'agent.layout': 'classic', 'chat.message.style': 'plain', 'chat.message.font_size': 14, 'chat.input.send_message_shortcut': 'Enter', diff --git a/src/renderer/components/composer/variants/AgentComposer.tsx b/src/renderer/components/composer/variants/AgentComposer.tsx index 3049f9a6713..6e0312b7a5b 100644 --- a/src/renderer/components/composer/variants/AgentComposer.tsx +++ b/src/renderer/components/composer/variants/AgentComposer.tsx @@ -171,17 +171,19 @@ const AgentComposerRoot = ({ const { agent } = useAgent(agentId) const { model: sessionModel } = useModelById((agent?.model ?? '') as UniqueModelId) const actionsRef = useRef({ ...emptyActions }) - const [sessionLayout] = usePreference('agent.layout') + const [sessionDisplayMode] = usePreference('agent.session.display_mode') + const isClassicSessionLayout = sessionDisplayMode === 'agent' const handleNewSessionShortcut = useCallback(() => { - if (sessionLayout === 'classic' && onCreateEmptySession) { + if (isClassicSessionLayout && onCreateEmptySession) { void onCreateEmptySession() return } void onNewSessionDraft?.() - }, [onCreateEmptySession, onNewSessionDraft, sessionLayout]) - const hasNewSessionShortcutAction = - sessionLayout === 'classic' ? Boolean(onCreateEmptySession || onNewSessionDraft) : Boolean(onNewSessionDraft) + }, [isClassicSessionLayout, onCreateEmptySession, onNewSessionDraft]) + const hasNewSessionShortcutAction = isClassicSessionLayout + ? Boolean(onCreateEmptySession || onNewSessionDraft) + : Boolean(onNewSessionDraft) const isActiveTab = useIsActiveTab() useCommandHandler('topic.create', handleNewSessionShortcut, { @@ -636,7 +638,8 @@ const AgentComposerInner = ({ const [fontSize] = usePreference('chat.message.font_size') const [narrowMode] = usePreference('chat.narrow_mode') const [sendMessageShortcut] = usePreference('chat.input.send_message_shortcut') - const [sessionLayout] = usePreference('agent.layout') + const [sessionDisplayMode] = usePreference('agent.session.display_mode') + const isClassicSessionLayout = sessionDisplayMode === 'agent' const { t } = useTranslation() const modelProviderName = useProviderDisplayName(model?.providerId) const agentModelFilter = useAgentModelFilter(agentBase?.type) @@ -800,7 +803,7 @@ const AgentComposerInner = ({ const label = t('agent.session.new') - if (sessionLayout === 'classic') { + if (isClassicSessionLayout) { if (!onCreateEmptySession) return [] return [ @@ -829,7 +832,7 @@ const AgentComposerInner = ({ } } ] - }, [agentBase, handleCreateEmptySession, onCreateEmptySession, onNewSessionDraft, sessionLayout, t]) + }, [agentBase, handleCreateEmptySession, isClassicSessionLayout, onCreateEmptySession, onNewSessionDraft, t]) const toolsSession = useMemo(() => { if (!sessionData) return undefined @@ -1035,7 +1038,7 @@ const AgentComposerInner = ({ selectModelLabel: t('button.select_model'), agentChanging, shouldAutoSelectCreatedAgent: Boolean(onAgentChange), - showAgentTrigger: sessionLayout !== 'classic', + showAgentTrigger: !isClassicSessionLayout, canChangeModel, onModelSelect: handleModelSelect, modelFilter: agentModelFilter, @@ -1142,7 +1145,8 @@ const MissingAgentHomeComposerInner = ({ const [enableSpellCheck] = usePreference('app.spell_check.enabled') const [fontSize] = usePreference('chat.message.font_size') const [sendMessageShortcut] = usePreference('chat.input.send_message_shortcut') - const [sessionLayout] = usePreference('agent.layout') + const [sessionDisplayMode] = usePreference('agent.session.display_mode') + const isClassicSessionLayout = sessionDisplayMode === 'agent' const { t } = useTranslation() const [text, setText] = useState('') const selectAgentMessage = t('chat.alerts.select_agent') @@ -1176,7 +1180,7 @@ const MissingAgentHomeComposerInner = ({ selectModelLabel: t('button.select_model'), agentChanging, shouldAutoSelectCreatedAgent: true, - showAgentTrigger: sessionLayout !== 'classic', + showAgentTrigger: !isClassicSessionLayout, canChangeModel: false, onAgentChange: handleAgentChange, onModelSelect: () => undefined diff --git a/src/renderer/components/composer/variants/ChatComposer.tsx b/src/renderer/components/composer/variants/ChatComposer.tsx index ec11bfa2757..d9955814818 100644 --- a/src/renderer/components/composer/variants/ChatComposer.tsx +++ b/src/renderer/components/composer/variants/ChatComposer.tsx @@ -442,8 +442,9 @@ const ChatComposerInner = ({ const [enableSpellCheck] = usePreference('app.spell_check.enabled') const [fontSize] = usePreference('chat.message.font_size') const [narrowMode] = usePreference('chat.narrow_mode') - // Classic layout has a left assistant rail, so the toolbar trigger edits the assistant instead of switching. - const [topicLayout] = usePreference('topic.layout') + // Assistant grouping uses the classic two-pane conversation layout. + const [topicDisplayMode] = usePreference('topic.tab.display_mode') + const isClassicTopicLayout = topicDisplayMode === 'assistant' const [searching, setSearching] = useCache('chat.web_search.searching') const [isMultiSelectMode] = useCache('chat.multi_select_mode') const { t } = useTranslation() @@ -670,7 +671,7 @@ const ChatComposerInner = ({ }, [onCreateEmptyTopic, selectedAssistantId]) const handleNewTopicShortcut = useCallback(() => { - if (topicLayout === 'classic' && onCreateEmptyTopic) { + if (isClassicTopicLayout && onCreateEmptyTopic) { if (isAssistantLoading || hasMissingPersistedAssistant) return handleCreateEmptyTopic() return @@ -679,7 +680,7 @@ const ChatComposerInner = ({ addNewTopic() }, [ addNewTopic, - topicLayout, + isClassicTopicLayout, handleCreateEmptyTopic, hasMissingPersistedAssistant, isAssistantLoading, @@ -689,7 +690,7 @@ const ChatComposerInner = ({ const rootPanelLeadingItems = useMemo(() => { const label = t('chat.conversation.new') - if (topicLayout === 'classic') { + if (isClassicTopicLayout) { if (!onCreateEmptyTopic) return [] const disabled = isAssistantLoading || hasMissingPersistedAssistant @@ -728,7 +729,7 @@ const ChatComposerInner = ({ onCreateEmptyTopic, onNewTopic, t, - topicLayout + isClassicTopicLayout ]) const handleSurfaceActionsChange = useCallback( @@ -997,7 +998,7 @@ const ChatComposerInner = ({ useMentionedModelSelector, shouldAutoSelectCreatedAssistant: Boolean(onDraftAssistantChange), selectModelLabel: runtimeModelPending ? t('common.loading') : t('button.select_model'), - showAssistantTrigger: topicLayout !== 'classic', + showAssistantTrigger: !isClassicTopicLayout, onAssistantChange: handleAssistantChange, onModelSelect: handleModelSelect, onMentionedModelsSelect: handleMentionedModelsSelect, diff --git a/src/renderer/components/composer/variants/__tests__/AgentComposer.test.tsx b/src/renderer/components/composer/variants/__tests__/AgentComposer.test.tsx index 81f70c07eef..79d33bb6843 100644 --- a/src/renderer/components/composer/variants/__tests__/AgentComposer.test.tsx +++ b/src/renderer/components/composer/variants/__tests__/AgentComposer.test.tsx @@ -384,7 +384,7 @@ vi.mock('@renderer/data/hooks/usePreference', () => ({ 'chat.message.font_size': 14, 'chat.narrow_mode': false, 'chat.input.send_message_shortcut': 'Enter', - 'agent.layout': mocks.sessionLayout + 'agent.session.display_mode': mocks.sessionLayout === 'classic' ? 'agent' : 'workdir' } return [values[key]] } diff --git a/src/renderer/components/composer/variants/__tests__/ChatComposer.test.tsx b/src/renderer/components/composer/variants/__tests__/ChatComposer.test.tsx index 5c890e36673..f989077a9db 100644 --- a/src/renderer/components/composer/variants/__tests__/ChatComposer.test.tsx +++ b/src/renderer/components/composer/variants/__tests__/ChatComposer.test.tsx @@ -377,7 +377,7 @@ vi.mock('@renderer/data/hooks/usePreference', () => ({ 'chat.message.font_size': 14, 'chat.narrow_mode': false, 'chat.input.send_message_shortcut': 'Enter', - 'topic.layout': mocks.topicLayout + 'topic.tab.display_mode': mocks.topicLayout === 'classic' ? 'assistant' : 'time' } return [values[key]] } diff --git a/src/shared/data/preference/__tests__/preferenceSchemas.test.ts b/src/shared/data/preference/__tests__/preferenceSchemas.test.ts index af651020dae..50f00187260 100644 --- a/src/shared/data/preference/__tests__/preferenceSchemas.test.ts +++ b/src/shared/data/preference/__tests__/preferenceSchemas.test.ts @@ -36,13 +36,8 @@ describe('DefaultPreferences', () => { expect(DefaultPreferences.default['agent.session.display_mode']).toBe(agentSessionDisplayDefault) }) - it('defaults both conversation and work surfaces to the classic layout for new users', () => { - const topicLayoutDefault: PreferenceSchemas['default']['topic.layout'] = 'classic' - const agentLayoutDefault: PreferenceSchemas['default']['agent.layout'] = 'classic' - - // preferenceSchemas.ts is generated from classification.json; pin the defaults so a - // regeneration that drops or flips either layout key fails loudly instead of shipping silently. - expect(DefaultPreferences.default['topic.layout']).toBe(topicLayoutDefault) - expect(DefaultPreferences.default['agent.layout']).toBe(agentLayoutDefault) + it('does not keep legacy classic/modern layout preferences', () => { + expect('topic.layout' in DefaultPreferences.default).toBe(false) + expect('agent.layout' in DefaultPreferences.default).toBe(false) }) }) diff --git a/src/shared/data/preference/preferenceSchemas.ts b/src/shared/data/preference/preferenceSchemas.ts index dcc0b14ec2d..208893e5c96 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-07-03T04:02:32.176Z + * Generated at: 2026-07-03T16:38:47.626Z * * This file is automatically generated from classification.json * To update this file, modify classification.json and run: @@ -36,8 +36,6 @@ import * as PreferenceTypes from '@shared/data/preference/preferenceTypes' export interface PreferenceSchemas { default: { - // target-key-definitions/complex/complex - 'agent.layout': PreferenceTypes.ChatLayoutMode // target-key-definitions/complex/complex 'agent.session.display_mode': PreferenceTypes.AgentSessionDisplayMode // redux/settings/enableDeveloperMode @@ -458,8 +456,6 @@ export interface PreferenceSchemas { 'shortcut.topic.rename': PreferenceTypes.PreferenceShortcutType // target-key-definitions/complex/complex 'shortcut.topic.sidebar.toggle': PreferenceTypes.PreferenceShortcutType - // target-key-definitions/complex/complex - 'topic.layout': PreferenceTypes.ChatLayoutMode // redux/settings/enableTopicNaming 'topic.naming.enabled': boolean // target-key-definitions/complex/complex @@ -494,7 +490,6 @@ export interface PreferenceSchemas { /* eslint sort-keys: ["error", "asc", {"caseSensitive": true, "natural": false}] */ export const DefaultPreferences: PreferenceSchemas = { default: { - 'agent.layout': 'classic', 'agent.session.display_mode': 'workdir', 'app.developer_mode.enabled': false, 'app.dist.auto_update.enabled': true, @@ -732,7 +727,6 @@ export const DefaultPreferences: PreferenceSchemas = { 'shortcut.topic.create': { binding: ['CommandOrControl', 'N'], enabled: true }, 'shortcut.topic.rename': { binding: ['CommandOrControl', 'T'], enabled: false }, 'shortcut.topic.sidebar.toggle': { binding: ['CommandOrControl', ']'], enabled: true }, - 'topic.layout': 'classic', 'topic.naming.enabled': true, 'topic.naming.model_id': null, 'topic.naming_prompt': '', @@ -759,7 +753,7 @@ export const DefaultPreferences: PreferenceSchemas = { /** * 生成统计: - * - 总配置项: 226 + * - 总配置项: 224 * - electronStore项: 1 * - redux项: 173 * - localStorage项: 0 diff --git a/src/shared/data/preference/preferenceTypes.ts b/src/shared/data/preference/preferenceTypes.ts index a35abba440d..2df4f112fbd 100644 --- a/src/shared/data/preference/preferenceTypes.ts +++ b/src/shared/data/preference/preferenceTypes.ts @@ -133,9 +133,6 @@ export enum UpgradeChannel { export type ChatMessageStyle = 'plain' | 'bubble' -/** Chat resource-list layout: 'classic' = entity rail + right resource panel, 'modern' = single sidebar. */ -export type ChatLayoutMode = 'classic' | 'modern' - export type ChatMessageNavigationMode = 'none' | 'buttons' | 'anchor' export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid' diff --git a/v2-refactor-temp/docs/breaking-changes/2026-06-26-chat-layout-split-assistant-agent.md b/v2-refactor-temp/docs/breaking-changes/2026-06-26-chat-layout-split-assistant-agent.md deleted file mode 100644 index e35a3e0b573..00000000000 --- a/v2-refactor-temp/docs/breaking-changes/2026-06-26-chat-layout-split-assistant-agent.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: Assistant and agent chat layouts are separate "classic/modern" layout settings -category: changed -severity: notice -introduced_in_pr: #16434 -date: 2026-06-26 ---- - -## What changed - -The single experimental `chat.resource_list.position` (`left`/`right`) preference is replaced by two independent settings under **Settings → Message Settings**: - -- **Conversation view** (assistant chats) — `topic.layout` -- **Work view** (agent chats) — `agent.layout` - -Each is **Modern layout** or **Classic layout**: - -- **Modern layout** — the single sidebar (topics/sessions listed in the left sidebar). -- **Classic layout** — a compact assistant/agent entity rail on the left plus the topic/session list in the right panel. - -Both settings default to **Classic layout** (the entity rail). - -The legacy v1 assistant `topicPosition` is deleted during v2 classification and is not migrated into either setting. Both new settings default to Classic layout. - -## Why this matters to the user - -Fresh installs and migrated users land on the **Classic layout**: a compact entity rail plus a right-side topic/session panel. Users can switch **Conversation view** or **Work view** to **Modern layout** for the single sidebar. - -The preference key also changed: the never-shipped `chat.resource_list.position` no longer exists. Anything expecting that key should read `topic.layout` / `agent.layout` instead. - -## What the user should do - -Nothing — automatic. Switch **Conversation view** / **Work view** in Settings → Message Settings to change layout. - -## Also changed: agent session options menu - -The agent session options menu drops its "toggle sidebar" item. This is an alpha behavior change, not data-affecting — documented here for completeness. - -## Also changed: default sidebar grouping mode - -This PR also changes the **default grouping** of the single-sidebar (Modern layout) lists — an intentional product decision, independent of the layout split: - -- `topic.tab.display_mode`: `assistant` → `time` -- `agent.session.display_mode`: `agent` → `workdir` - -Neither key has a v1 migration mapping, so the new default applies to fresh installs **and** users migrating from v1. This is **not** a breaking change — the feature is still in alpha, and existing stored grouping values still render. The current UI does not expose a full display-mode switcher, though: conversation topics have no display-mode menu in the single sidebar, and agent sessions expose only **Time** / **Workdir** choices. Documented here only for completeness. - -## Notes for release manager - -This entry supersedes the in-development `chat.resource_list.position` default of `right`; that key never shipped to a release, so only the new `classic/modern` framing needs translating. diff --git a/v2-refactor-temp/docs/chat/chat-layout-modes.md b/v2-refactor-temp/docs/chat/chat-layout-modes.md index 2157869d621..00b311f1dc5 100644 --- a/v2-refactor-temp/docs/chat/chat-layout-modes.md +++ b/v2-refactor-temp/docs/chat/chat-layout-modes.md @@ -1,248 +1,26 @@ -# Chat Layout Modes (classic / modern) +# Chat Layout Modes -Each chat surface — assistant conversations (Home) and agent work — has its own -layout-mode preference. The **classic layout** is a compact entity rail plus a right-side -resource panel; the **modern layout** is the single sidebar. The `classic` / `modern` -values are the persisted preference values and feed the `isClassicTopicLayout` / -`isClassicSessionLayout` flags in the pages. +Home and Agent no longer persist a separate manual `classic` / `modern` layout +preference. The layout is derived from the resource-list display mode. -## Preferences +## Home -Two independent preferences, both `'classic' | 'modern'` (`PreferenceTypes.ChatLayoutMode`), -both defaulting to `'classic'`: +- `topic.tab.display_mode = 'assistant'` uses the classic layout: + assistant rail on the left, chat in the center, topic list in the right pane. +- `topic.tab.display_mode = 'time'` uses the modern single-sidebar layout. -- `topic.layout` — assistant chats (Home). v2-only, no v1 source. -- `agent.layout` — agent chats. v2-only, no v1 source. +## Agent -Both are declared in `target-key-definitions.json` and generated into -`preferenceSchemas.ts`; the legacy v1 `topicPosition` field is deleted during -classification and is not migrated into either setting. The settings UI -(`CommonSettings`) exposes them as "Conversation view" and "Work view", each with -"Classic" / "Modern" labels, under the `settings.messages.layout.classic` / `.modern` -keys (Chinese: 经典 / 现代). - -## Surface terminology - -The two surfaces deliberately use different words at different layers; the mapping is -fixed, not accidental: - -| Surface | Domain entity | Resource | Preference namespace | UI label (i18n) | -| --------- | ------------- | ----------------- | -------------------- | ------------------- | -| Assistant | `assistant` | topic | `topic.*` | "Conversation view" | -| Agent | `agent` | session ("work") | `agent.*` | "Work view" | - -So `topic.layout` governs the assistant surface and `agent.layout` governs the agent -surface, while the shared rail/panel components stay entity-generic -(`ResourceEntityRail`, `ConversationPickerDialog`). The layout *mode* itself is always -`classic` / `modern` regardless of surface. - -## Layout - -When the relevant preference is `classic`: - -1. Left: a compact assistant/agent entity rail. -2. Center: the existing chat surface. -3. Right: an independently toggleable resource panel for the current - assistant/agent's conversations/works. - -When the preference is `modern`, the single sidebar is used -(`HomeTabs` / `AgentSidePanel`). Its display-mode preferences and logic are kept, -though the display-mode controls are currently hidden in the UI. +- `agent.session.display_mode = 'agent'` uses the classic layout: + agent rail on the left, chat in the center, session list in the right pane. +- `agent.session.display_mode = 'time'` or `'workdir'` uses the modern + single-sidebar layout. ## State -- `topic.layout` / `agent.layout` select the mode per surface. -- `topic.tab.show` controls whether the left entity rail is expanded/collapsed. -- The classic-layout right panel's open state is persisted per surface via the shared - `useClassicLayoutRightPaneOpen(surface, isClassic)` hook: `ui.chat.right_pane_open` - for the assistant surface and `ui.agent.right_pane_open` for the agent surface, so the - two don't bleed into each other. Home uses it directly on the page-level Shell; Agent - owns the same cached state in `AgentPage` and threads it through each `AgentChat` Shell - mount so it survives draft → persistent remounts and page re-entry. -- Toggling a surface modern → classic → modern restores the last cached classic-layout - right-panel open state. - -## Left entity rail - -`ResourceEntityRail` (presentational, generic) + `useResourceEntityRail` (shared -behavior). The per-variant adapters `AssistantResourceList` / `AgentResourceList` -own data fetching, pins, deletion, and context menus. - -### Scope - -- Home shows assistants; Agent shows agents. -- Only entities that already own conversations/works are shown — visibility is - derived from the shared resource list (`getResourceParentId`), not a separate - query. -- Newly created assistants/agents stay hidden until they have at least one - topic/session. - -### Top action - -- Fixed above the sortable entity list: Home adds an assistant, Agent adds an - agent. Entities cannot be dragged above it. -- The "+" opens a shared searchable picker (`ConversationPickerDialog`, wrapped - per surface by `AssistantConversationPickerDialog` / `AgentConversationPickerDialog`) - to switch to or create an entity. It has a "create new" row and pagination; for - assistants it filters between 资源库 (the user's assistants) and 助手库 (the - preset catalog, via `useAssistantCatalogPresets`). -- A history-records button next to "+" opens the History Records / global-history - page (`onOpenHistoryRecords`). -- After creating a new entity the main chat enters its blank state; the entity - still does not appear in the rail until it owns a topic/session. - -### Selection and click behavior - -- Selection follows the current assistantId/agentId; if the current entity has no - resources it has no selected row. -- `handleSelect` enters the entity's first/most-recent resource (pinned then time - order via `sortResourcesForEntity`). Because a visible entity always owns at - least one *loaded* resource, this does **not** wait for the full load — there is - no dead-click window. The (effectively unreachable) no-resource case falls back - to a blank draft. -- Clicking an entity does not open the right panel if it is closed; if it is open - it stays open and switches to the new entity. - -### Pinned entities - -- Pinned assistants/agents float into a "已固定" section at the top, mirroring the - modern layout's left list (entity pins reuse `usePins('assistant'|'agent')`). The rest - sit under a "助手" / "智能体" section below. -- Both are collapsible **section** headers (flush-left), so the entity rows keep - their avatar and read as indented beneath. With nothing pinned the rail renders a - single flat list with no header — same as the modern layout's single-section case. -- Pinned rows cannot be dragged and nothing can be dropped into the pinned section; - only the entities still owning resources appear (the rail's visibility invariant - is unchanged). - -### Ordering & context menu - -- Non-pinned entities are ordered by assistant/agent `orderKey`; drag reorders and - persists the real `orderKey` (optimistic, then refetch). -- Entity rows keep the single sidebar's entity context menus (assistant grouped-row / - agent `AgentItem` behavior). Deleting the current entity, or clearing all its - resources, closes the right panel and leaves the main chat in that entity's - blank state. - -## Right resource panel - -The right panel reuses the existing `Shell` right-pane chrome. The topic/session -list is injected as the first `resources` tab via `ResourcePaneProvider` / -`useResourcePane` (a context, so the node + label are supplied once at the page -level instead of prop-threaded). - -- Home lists topics ("topic" / "话题"); Agent lists works ("work" / "工作"). -- Lists only the current entity's resources. With no current entity the panel - opens to an empty list. -- The panel is mutually exclusive with branch/trace/files/status/flow - (scoped to the current chat instance). When it is open, the panel header owns the - close/toggle control. -- While the right panel is closed, `ConversationShell` mirrors the stable internal - tab icons into the top-right tool area as tab shortcuts, next to the existing - right-panel expand button. Clicking a shortcut opens the right panel directly to - that tab; clicking the expand button opens the default tab. Once the panel is open - or maximized, the whole top-right tool cluster disappears and the internal tab strip - owns tab switching / close controls. Dynamic Agent tabs (`flow:*`, `file-preview`) - stay inside the panel only; the Agent Status shortcut keeps the same hover preview - as the previous status summary entry. -- Fixed time grouping, groups expanded by default; does not read/write the - single sidebar's group-collapsed state or display options. Header keeps only search, - scoped to the current entity; creating a topic/session stays on the left rail - and single sidebar entry points. Drag/group movement is disabled (the list is - fixed time-grouped). -- Switching assistant/agent clears the right-list search; switching topic/session - within the same entity does not. - -## Composer entity controls (classic layout) - -In classic layout the left rail owns entity switching, so the composer's assistant/agent -switcher is hidden rather than repurposed: - -- `ChatComposer` passes `showAssistantTrigger: topicLayout !== 'classic'`. -- `AgentComposer` passes `showAgentTrigger: sessionLayout !== 'classic'`. The - `agentTriggerMode="edit"` code path still exists for toolbar-bound contexts, but - it is not the classic-layout entry point because the trigger is hidden by - `showAgentTrigger`. -- Classic layout adds a new conversation/work action to the composer controls when - `onCreateEmptyTopic` / `onCreateEmptySession` is available. -- The agent composer's inline model selector remains available for changing the - active agent model. The agent switcher is hidden inside active sessions (an - active session is bound to its agent). - -## Agent workspace control (classic layout) - -Classic-layout agent chats keep the workspace control visible in the composer because -the classic left sidebar is no longer present: - -- Draft sessions keep the existing editable workspace selector. -- Persistent sessions can switch workspace only while the visible session is still - empty: messages are loaded, there are no older pages, and the UI message list is - empty. After any message exists, the same control remains visible as a read-only - workspace display. -- Switching patches the current session's workspace source (`user` workspace id or - `system` / no-project) instead of replacing the draft state. The data service - rejects workspace updates once the session has messages. -- The no-project / system workspace is not a user filesystem workspace. It is shown - as the no-project option, but it does not run directory accessibility preflight - and is not passed to skills or tool accessible paths. Only `user` workspaces expose - their path to those checks. - -## Data flow - -No DataApi endpoint filters topics/sessions by entity — both panes derive from one -shared full list and filter in the frontend. - -- The entity rail and the right panel read the **same** source through - `useAssistantTopicsSource` / `useAgentSessionsSource` - (`src/renderer/hooks/resourceViewSources.ts`). These wrap - `useTopics({ loadAll: true })` / `useSessions(undefined, { loadAll: true, - pageSize })` so both sides resolve to one SWR key — one fetch, and the load - options can never drift between the two call sites. -- `loadAll` is intentional and unavoidable: the rail must know which entities own - resources, and the panel filters the same list by the current entity. A single - fetch feeds both. -- Create/delete/rename/clear/move use the existing mutation/invalidate - flow; after a mutation the shared source is refreshed once and both sides - re-derive. No local shadow copies. -- Assistant/agent metadata supplies display data + operations (name, emoji/avatar, - `orderKey`, context-menu actions); topic/session data determines visibility. -- `PATCH /agent-sessions/:sessionId` accepts a normalized `workspace` source for - empty-session workspace switches. The main service maps it to the backing - `workspaceId`, creates or removes system workspaces as needed, and refuses the - update after messages exist. - -## Agent pane persistence across the draft→persistent handoff - -Home keeps a single page-level `Shell` (via `renderWithRightPane`), so its right -pane stays open across the draft → persistent topic handoff. The agent chat mounts -a fresh `AgentRightPane` (= a fresh `Shell`) per conversation branch -(initializing / draft / missing-agent / persistent), so sending the first message -in a draft session would otherwise remount the Shell and snap the work panel shut. - -To match Home, the `Shell` exposes an additive `onOpenChange` callback; -`AgentPage` owns the open state (`sessionPaneOpen`) and threads -`defaultOpen` + `onOpenChange` through `AgentChat` to every `AgentRightPane` mount -site, so the open state survives the remount. This is scoped to the classic session -layout (`isClassicSessionLayout`); modern layout passes `undefined` and is byte-for-byte unchanged. - -## Key files - -- `components/chat/resources/variants/ResourceEntityRail.tsx`, - `useResourceEntityRail.ts` — rail component + shared behavior. -- `components/chat/resources/variants/AssistantResourceList.tsx`, - `AgentResourceList.tsx` — per-variant data adapters. -- `components/chat/panes/Shell/resourcePane.tsx` — `resources` tab injection. -- `components/resource/ConversationPickerDialog.tsx` — shared entity/conversation - picker; wrapped by `pages/home/components/AssistantConversationPickerDialog.tsx` - and `pages/agents/components/AgentConversationPickerDialog.tsx`. -- `components/composer/variants/ChatComposer.tsx`, - `AgentComposer.tsx` — classic-layout entity trigger visibility and new conversation/work - composer actions. -- `hooks/useAssistantCatalogPresets.ts` — preset catalog feeding the assistant picker. -- `hooks/resourceViewSources.ts` — shared full-list sources. -- `pages/home/HomePage.tsx`, `pages/agents/AgentPage.tsx`, - `pages/agents/AgentChat.tsx`, `pages/agents/AgentComposerSlot.tsx` — page wiring, - agent pane persistence, and classic-layout workspace switching. -- `main/data/services/AgentSessionService.ts`, - `shared/data/api/schemas/agentSessions.ts` — empty-session workspace update - validation and persistence. +- Display mode is stored as Preference data. +- Classic-layout right pane open state is stored per surface in renderer + persist cache: `ui.chat.right_pane_open` for Home and + `ui.agent.right_pane_open` for Agent. +- Resource-list collapsed groups are also stored per display mode in renderer + persist cache. 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 2643309092e..cbcdb317ea7 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 @@ -103,20 +103,6 @@ "status": "classified", "description": "Web search provider overrides (from complex migration)" }, - { - "targetKey": "topic.layout", - "type": "PreferenceTypes.ChatLayoutMode", - "defaultValue": "classic", - "status": "classified", - "description": "Assistant conversation layout: 'classic' = entity rail + right topic panel, 'modern' = single topic sidebar (new v2 preference, no v1 source)" - }, - { - "targetKey": "agent.layout", - "type": "PreferenceTypes.ChatLayoutMode", - "defaultValue": "classic", - "status": "classified", - "description": "Agent work layout: 'classic' = entity rail + right session panel, 'modern' = single session sidebar (v2 new feature, no v1 source)" - }, { "targetKey": "ui.sidebar.favorites", "type": "PreferenceTypes.SidebarFavoriteItem[]", From c331e2558271203bb68f5897249a20a3fb8ccba8 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sat, 4 Jul 2026 00:58:21 +0800 Subject: [PATCH 03/40] refactor: update right pane dimensions and adjust group header behavior for right panel --- .../chat/resources/ResourceListContext.ts | 2 +- .../chat/resources/ResourceListGroups.tsx | 60 ++++++++++++------- .../shell/__tests__/RightPaneHost.test.tsx | 2 +- .../components/chat/shell/paneLayout.ts | 4 +- .../__tests__/AgentChatArtifactPane.test.tsx | 14 ++--- .../agents/__tests__/AgentChatLocate.test.tsx | 4 +- .../__tests__/AgentChatSettingsPanel.test.tsx | 8 +-- .../pages/home/Tabs/components/Topics.tsx | 36 +++++------ .../Tabs/components/__tests__/Topics.test.tsx | 24 ++++++++ .../__tests__/TopicRightPane.test.tsx | 4 +- 10 files changed, 95 insertions(+), 63 deletions(-) diff --git a/src/renderer/components/chat/resources/ResourceListContext.ts b/src/renderer/components/chat/resources/ResourceListContext.ts index 2130352db2d..2317c5d7059 100644 --- a/src/renderer/components/chat/resources/ResourceListContext.ts +++ b/src/renderer/components/chat/resources/ResourceListContext.ts @@ -42,7 +42,7 @@ export type ResourceListGroupHeaderIconContext = { collapsed: boolean } -export type ResourceListGroupHeaderClickBehavior = 'toggle' | 'select-first-then-toggle' +export type ResourceListGroupHeaderClickBehavior = 'toggle' | 'select-first-then-toggle' | 'none' export type ResourceListSortOption = { id: string diff --git a/src/renderer/components/chat/resources/ResourceListGroups.tsx b/src/renderer/components/chat/resources/ResourceListGroups.tsx index 8f01849ca66..a042098ef5d 100644 --- a/src/renderer/components/chat/resources/ResourceListGroups.tsx +++ b/src/renderer/components/chat/resources/ResourceListGroups.tsx @@ -104,6 +104,7 @@ export function GroupHeader({ group, className, ref, style, onContextMenu, ...pr const collapsed = groupState.collapsed const groupItems = viewGroup?.allItems ?? EMPTY_GROUP_HEADER_ITEMS const clickBehavior = meta.getGroupHeaderClickBehavior(group) + const isCollapsible = clickBehavior !== 'none' const selected = clickBehavior === 'select-first-then-toggle' && groupState.selected const groupHeaderContext = { collapsed } const groupHeaderAction = meta.getGroupHeaderAction?.(group) @@ -121,6 +122,8 @@ export function GroupHeader({ group, className, ref, style, onContextMenu, ...pr [onContextMenu] ) const handleClick = useCallback(() => { + if (!isCollapsible) return + if (clickBehavior === 'select-first-then-toggle' && !selected) { const firstItem = groupItems[0] if (firstItem) { @@ -135,7 +138,7 @@ export function GroupHeader({ group, className, ref, style, onContextMenu, ...pr } actions.toggleGroup(group.id) - }, [actions, clickBehavior, group, group.id, groupItems, meta, selected]) + }, [actions, clickBehavior, group, groupItems, isCollapsible, meta, selected]) // Lazy: ContextMenuContent stays empty until right-click — stopPropagated bubbles must not reveal items. const headerContextMenuItems = @@ -175,27 +178,40 @@ export function GroupHeader({ group, className, ref, style, onContextMenu, ...pr {groupHeaderLeadingAction}
)} - + {isCollapsible ? ( + + ) : ( +
+ {groupHeaderIcon && ( + + )} + + {group.label} + +
+ )} {groupHeaderAction && (
{ - const state = { width: 460 } + const state = { width: 300 } return { state, diff --git a/src/renderer/components/chat/shell/paneLayout.ts b/src/renderer/components/chat/shell/paneLayout.ts index a503a73a4af..f39949b74e7 100644 --- a/src/renderer/components/chat/shell/paneLayout.ts +++ b/src/renderer/components/chat/shell/paneLayout.ts @@ -14,7 +14,7 @@ export const RESOURCE_LIST_PANE_COLLAPSE_DRAG_THRESHOLD = 200 export const RESOURCE_LIST_PANE_AUTO_COLLAPSE_WIDTH = 540 export const RESOURCE_LIST_PANE_CACHE_KEY = 'ui.chat.sidebar.width' -export const ARTIFACT_RIGHT_PANE_MIN_WIDTH = 360 -export const ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH = 460 +export const ARTIFACT_RIGHT_PANE_MIN_WIDTH = 300 +export const ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH = 300 export const ARTIFACT_RIGHT_PANE_MAX_WIDTH = 720 export const ARTIFACT_RIGHT_PANE_CACHE_KEY = 'ui.chat.artifact_pane.width' diff --git a/src/renderer/pages/agents/__tests__/AgentChatArtifactPane.test.tsx b/src/renderer/pages/agents/__tests__/AgentChatArtifactPane.test.tsx index 827fba3b55a..444307ca306 100644 --- a/src/renderer/pages/agents/__tests__/AgentChatArtifactPane.test.tsx +++ b/src/renderer/pages/agents/__tests__/AgentChatArtifactPane.test.tsx @@ -31,9 +31,9 @@ vi.mock('@cherrystudio/ui', async (importOriginal) => ({ vi.mock('@renderer/components/chat', () => ({ ARTIFACT_RIGHT_PANE_CACHE_KEY: 'ui.chat.artifact_pane.width', - ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 460, + ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 300, ARTIFACT_RIGHT_PANE_MAX_WIDTH: 720, - ARTIFACT_RIGHT_PANE_MIN_WIDTH: 360, + ARTIFACT_RIGHT_PANE_MIN_WIDTH: 300, ConversationCenterState: ({ state }: { state: string }) => (
), @@ -119,9 +119,9 @@ vi.mock('@renderer/components/chat', () => ({ vi.mock('@renderer/components/chat/shell/RightPaneHost', () => ({ ARTIFACT_RIGHT_PANE_CACHE_KEY: 'ui.chat.artifact_pane.width', - ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 460, + ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 300, ARTIFACT_RIGHT_PANE_MAX_WIDTH: 720, - ARTIFACT_RIGHT_PANE_MIN_WIDTH: 360, + ARTIFACT_RIGHT_PANE_MIN_WIDTH: 300, RightPaneHost: ({ children, open, @@ -672,10 +672,10 @@ describe('AgentChat artifact pane', () => { fireEvent.click(shortcut) expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-open', 'true') - expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-width', '460') + expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-width', '300') expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-resizable', 'true') - expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-min-width', '360') - expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-default-width', '460') + expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-min-width', '300') + expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-default-width', '300') expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-max-width', '720') expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-cache-key', 'ui.chat.artifact_pane.width') expect(screen.getByTestId('artifact-right-pane').getAttribute('data-class-name')).not.toContain('p-2') diff --git a/src/renderer/pages/agents/__tests__/AgentChatLocate.test.tsx b/src/renderer/pages/agents/__tests__/AgentChatLocate.test.tsx index b5c0369f82b..f4da5d68fac 100644 --- a/src/renderer/pages/agents/__tests__/AgentChatLocate.test.tsx +++ b/src/renderer/pages/agents/__tests__/AgentChatLocate.test.tsx @@ -30,9 +30,9 @@ vi.mock('@cherrystudio/ui', async (importOriginal) => ({ vi.mock('@renderer/components/chat', () => ({ ARTIFACT_RIGHT_PANE_CACHE_KEY: 'ui.chat.artifact_pane.width', - ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 460, + ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 300, ARTIFACT_RIGHT_PANE_MAX_WIDTH: 720, - ARTIFACT_RIGHT_PANE_MIN_WIDTH: 360, + ARTIFACT_RIGHT_PANE_MIN_WIDTH: 300, ChatAppShell: ({ pane, paneOpen, diff --git a/src/renderer/pages/agents/__tests__/AgentChatSettingsPanel.test.tsx b/src/renderer/pages/agents/__tests__/AgentChatSettingsPanel.test.tsx index 7e3c85a7e21..6a1e3e709e5 100644 --- a/src/renderer/pages/agents/__tests__/AgentChatSettingsPanel.test.tsx +++ b/src/renderer/pages/agents/__tests__/AgentChatSettingsPanel.test.tsx @@ -38,9 +38,9 @@ vi.mock('@renderer/ipc', () => ({ vi.mock('@renderer/components/chat', () => ({ ARTIFACT_RIGHT_PANE_CACHE_KEY: 'ui.chat.artifact_pane.width', - ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 460, + ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 300, ARTIFACT_RIGHT_PANE_MAX_WIDTH: 540, - ARTIFACT_RIGHT_PANE_MIN_WIDTH: 360, + ARTIFACT_RIGHT_PANE_MIN_WIDTH: 300, ConversationCenterState: ({ state }: { state: string }) => (
), @@ -78,9 +78,9 @@ vi.mock('@renderer/components/chat', () => ({ vi.mock('@renderer/components/chat/shell/RightPaneHost', () => ({ ARTIFACT_RIGHT_PANE_CACHE_KEY: 'ui.chat.artifact_pane.width', - ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 460, + ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 300, ARTIFACT_RIGHT_PANE_MAX_WIDTH: 540, - ARTIFACT_RIGHT_PANE_MIN_WIDTH: 360, + ARTIFACT_RIGHT_PANE_MIN_WIDTH: 300, RightPaneHost: ({ children, open }: PropsWithChildren<{ open?: boolean }>) => (
{open ? children : null} diff --git a/src/renderer/pages/home/Tabs/components/Topics.tsx b/src/renderer/pages/home/Tabs/components/Topics.tsx index aa87c533685..fdf3e54aa2f 100644 --- a/src/renderer/pages/home/Tabs/components/Topics.tsx +++ b/src/renderer/pages/home/Tabs/components/Topics.tsx @@ -83,6 +83,7 @@ import { import { useTopicMenuActions } from './useTopicMenuActions' const logger = loggerService.withContext('Topics') +const EMPTY_COLLAPSED_TOPIC_STATE: readonly string[] = [] interface Props { activeTopic?: Topic @@ -226,16 +227,7 @@ export function Topics({ }) const displayMode = isRightPanel ? 'time' : (topicDisplayMode ?? 'time') const isAssistantDisplayMode = displayMode === 'assistant' - const [rightPanelTopicExpansion, setRightPanelTopicExpansion] = useState([]) - const topicExpansion = isRightPanel - ? rightPanelTopicExpansion - : isAssistantDisplayMode - ? topicExpansionAssistant - : topicExpansionTime - - useEffect(() => { - if (isRightPanel) setRightPanelTopicExpansion([]) - }, [assistantIdFilter, isRightPanel]) + const topicExpansion = isAssistantDisplayMode ? topicExpansionAssistant : topicExpansionTime const { isLoading: isTopicPinsLoading, @@ -631,9 +623,12 @@ export function Topics({ [activeTopic?.id, filteredTopics, setActiveTopic] ) const getGroupHeaderClickBehavior = useCallback( - (group: { id: string }) => - displayMode === 'assistant' && group.id !== TOPIC_PINNED_GROUP_ID ? 'select-first-then-toggle' : 'toggle', - [displayMode] + (group: { id: string }) => { + if (isRightPanel) return 'none' + + return displayMode === 'assistant' && group.id !== TOPIC_PINNED_GROUP_ID ? 'select-first-then-toggle' : 'toggle' + }, + [displayMode, isRightPanel] ) const listError = error || (isAssistantDisplayMode ? assistantsError : undefined) const listLoading = @@ -870,13 +865,10 @@ export function Topics({ [assistantById, defaultAssistant.emoji, defaultAssistant.name, isAssistantDisplayMode] ) - const collapsedTopicState = topicExpansion + const collapsedTopicState = isRightPanel ? EMPTY_COLLAPSED_TOPIC_STATE : topicExpansion const handleTopicCollapsedStateChange = useCallback( (nextCollapsedIds: string[]) => { - if (isRightPanel) { - setRightPanelTopicExpansion(nextCollapsedIds) - return - } + if (isRightPanel) return if (isAssistantDisplayMode) setTopicExpansionAssistant(nextCollapsedIds) else setTopicExpansionTime(nextCollapsedIds) @@ -1043,8 +1035,8 @@ export function Topics({ sectionBy={topicSectionBy} collapsedState={collapsedTopicState} revealRequest={revealRequest} - defaultGroupVisibleCount={5} - groupLoadStep={5} + defaultGroupVisibleCount={isRightPanel ? Number.POSITIVE_INFINITY : 5} + groupLoadStep={isRightPanel ? Number.POSITIVE_INFINITY : 5} getGroupHeaderAction={getGroupHeaderAction} getGroupHeaderContextMenu={getGroupHeaderContextMenu} getGroupHeaderIcon={getGroupHeaderIcon} @@ -1059,8 +1051,8 @@ export function Topics({ canDropGroup={canDropTopicGroup} canDragItem={canDragTopicItem} canDropItem={canDropTopicItem} - groupShowMoreLabel={t('chat.topics.group.show_more')} - groupCollapseLabel={t('chat.topics.group.collapse')} + groupShowMoreLabel={isRightPanel ? undefined : t('chat.topics.group.show_more')} + groupCollapseLabel={isRightPanel ? undefined : t('chat.topics.group.collapse')} onRenameItem={handleRenameTopic} onGroupHeaderSelectItem={handleGroupHeaderSelectTopic} onReorder={handleTopicReorder} diff --git a/src/renderer/pages/home/Tabs/components/__tests__/Topics.test.tsx b/src/renderer/pages/home/Tabs/components/__tests__/Topics.test.tsx index 7cc6eefdfa1..d17974f87f1 100644 --- a/src/renderer/pages/home/Tabs/components/__tests__/Topics.test.tsx +++ b/src/renderer/pages/home/Tabs/components/__tests__/Topics.test.tsx @@ -822,6 +822,30 @@ describe('Topics', () => { expect(screen.getByRole('textbox', { name: 'Search conversations' })).toBeInTheDocument() }) + it('keeps right panel groups fully expanded without collapse controls', () => { + mockUseInfiniteQuery.mockReturnValue({ + pages: [{ items: createTopicPageItems(6) }], + isLoading: false, + isRefreshing: false, + error: undefined, + hasNext: false, + loadNext: vi.fn(), + refresh: vi.fn(), + reset: vi.fn(), + mutate: vi.fn() + }) + + renderTopicList({ assistantIdFilter: 'assistant-1', presentation: 'right-panel' }) + + expect(screen.getByText('Today')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Today' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { expanded: true })).not.toBeInTheDocument() + expect(screen.queryByText('Show more conversations')).not.toBeInTheDocument() + expect(screen.queryByText('Collapse conversations')).not.toBeInTheDocument() + expect(screen.getByText('Topic 1')).toBeInTheDocument() + expect(screen.getByText('Topic 6')).toBeInTheDocument() + }) + it('forces time grouping in the right panel even when the assistant display mode is stored', () => { // beforeEach stores topic.tab.display_mode: 'assistant'. The classic right panel is the parent // switch and must ignore the stored display mode, grouping strictly by time. The observable diff --git a/src/renderer/pages/home/components/__tests__/TopicRightPane.test.tsx b/src/renderer/pages/home/components/__tests__/TopicRightPane.test.tsx index 7977e28e91a..873c9220b56 100644 --- a/src/renderer/pages/home/components/__tests__/TopicRightPane.test.tsx +++ b/src/renderer/pages/home/components/__tests__/TopicRightPane.test.tsx @@ -36,9 +36,9 @@ vi.mock('@renderer/components/chat/shell/RightPaneHost', async () => { return { ARTIFACT_RIGHT_PANE_CACHE_KEY: 'ui.chat.artifact_pane.width', - ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 460, + ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 300, ARTIFACT_RIGHT_PANE_MAX_WIDTH: 720, - ARTIFACT_RIGHT_PANE_MIN_WIDTH: 360, + ARTIFACT_RIGHT_PANE_MIN_WIDTH: 300, RightPaneHost: ({ children, onCloseAnimationComplete, From eeb74c98e5d0571e7ba680173ca0ef5d29ab63f0 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sat, 4 Jul 2026 01:17:55 +0800 Subject: [PATCH 04/40] refactor: add manage agents and assistants functionality to resource menus --- .../chat/resources/SessionListOptionsMenu.tsx | 17 +++++++++ .../chat/resources/TopicListOptionsMenu.tsx | 23 +++++++++++- .../resources/variants/AgentResourceList.tsx | 4 ++- .../variants/AssistantResourceList.tsx | 4 ++- src/renderer/i18n/locales/en-us.json | 3 ++ src/renderer/i18n/locales/zh-cn.json | 3 ++ src/renderer/i18n/locales/zh-tw.json | 3 ++ .../pages/agents/__tests__/AgentPage.test.tsx | 13 +++---- .../pages/agents/components/Sessions.tsx | 5 +-- .../components/__tests__/Sessions.test.tsx | 13 ++++--- .../pages/home/Tabs/components/Topics.tsx | 5 +-- .../Tabs/components/__tests__/Topics.test.tsx | 19 ++++++++-- .../pages/home/__tests__/HomePage.test.tsx | 36 ++++++++++--------- 13 files changed, 113 insertions(+), 35 deletions(-) diff --git a/src/renderer/components/chat/resources/SessionListOptionsMenu.tsx b/src/renderer/components/chat/resources/SessionListOptionsMenu.tsx index 9491d4fb0da..16500f7d70f 100644 --- a/src/renderer/components/chat/resources/SessionListOptionsMenu.tsx +++ b/src/renderer/components/chat/resources/SessionListOptionsMenu.tsx @@ -19,15 +19,19 @@ const SESSION_DISPLAY_ICONS: Record = { } type SessionListOptionsMenuProps = { + manageAgentsActive?: boolean mode: AgentSessionDisplayMode onChange: (mode: AgentSessionDisplayMode) => void + onManageAgents?: () => void | Promise onOpenHistoryRecords?: () => void sectionId?: string } export function SessionListOptionsMenu({ + manageAgentsActive, mode, onChange, + onManageAgents, onOpenHistoryRecords, sectionId }: SessionListOptionsMenuProps) { @@ -87,6 +91,19 @@ export function SessionListOptionsMenu({ }} /> )} + {onManageAgents && } + {onManageAgents && ( + } + label={t('agent.manage.title')} + active={manageAgentsActive} + onClick={() => { + void onManageAgents() + setOpen(false) + }} + /> + )} diff --git a/src/renderer/components/chat/resources/TopicListOptionsMenu.tsx b/src/renderer/components/chat/resources/TopicListOptionsMenu.tsx index 07f609f6baa..b32818cf79e 100644 --- a/src/renderer/components/chat/resources/TopicListOptionsMenu.tsx +++ b/src/renderer/components/chat/resources/TopicListOptionsMenu.tsx @@ -17,12 +17,20 @@ const TOPIC_DISPLAY_ICONS: Record = { } type TopicListOptionsMenuProps = { + manageAssistantsActive?: boolean mode: TopicDisplayMode onChange: (mode: TopicDisplayMode) => void + onManageAssistants?: () => void | Promise onOpenHistoryRecords?: () => void } -export function TopicListOptionsMenu({ mode, onChange, onOpenHistoryRecords }: TopicListOptionsMenuProps) { +export function TopicListOptionsMenu({ + manageAssistantsActive, + mode, + onChange, + onManageAssistants, + onOpenHistoryRecords +}: TopicListOptionsMenuProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -61,6 +69,19 @@ export function TopicListOptionsMenu({ mode, onChange, onOpenHistoryRecords }: T }} /> )} + {onManageAssistants && } + {onManageAssistants && ( + } + label={t('assistants.presets.manage.title')} + active={manageAssistantsActive} + onClick={() => { + void onManageAssistants() + setOpen(false) + }} + /> + )} diff --git a/src/renderer/components/chat/resources/variants/AgentResourceList.tsx b/src/renderer/components/chat/resources/variants/AgentResourceList.tsx index 18f50584758..c6a40d16c75 100644 --- a/src/renderer/components/chat/resources/variants/AgentResourceList.tsx +++ b/src/renderer/components/chat/resources/variants/AgentResourceList.tsx @@ -82,6 +82,7 @@ export function AgentResourceList({ const { trigger: reorderAgent } = useMutation('PATCH', '/agents/:id/order', { refresh: ['/agents'] }) const [deletingAgentId, setDeletingAgentId] = useState(null) const [editDialogTarget, setEditDialogTarget] = useState(null) + const manageAgentsMenuItem = resourceMenuItems?.find((item) => item.id === 'agent-resource-view') const agentPinnedIdSet = useMemo(() => new Set(agentPinnedIds), [agentPinnedIds]) const isAgentPinActionDisabled = isAgentPinsLoading || isAgentPinsRefreshing || isAgentPinsMutating const sessionItems = useMemo( @@ -270,12 +271,13 @@ export function AgentResourceList({ onAdd={onAddAgent ?? (() => onStartMissingAgentDraft?.())} headerActions={ void setSessionDisplayMode(nextMode)} + onManageAgents={manageAgentsMenuItem?.onSelect} onOpenHistoryRecords={onOpenHistoryRecords} /> } - resourceMenuItems={resourceMenuItems} onSelect={handleSelect} onReorder={handleReorder} getContextMenuActions={getContextMenuActions} diff --git a/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx b/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx index c0a11ce1141..c01d1f15a34 100644 --- a/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx +++ b/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx @@ -55,6 +55,7 @@ export function AssistantResourceList({ const [assistantSortType, setAssistantSortType] = usePreference('assistant.tab.sort_type') const [topicDisplayMode, setTopicDisplayMode] = usePreference('topic.tab.display_mode') const isTagGrouping = assistantSortType === 'tags' + const manageAssistantsMenuItem = resourceMenuItems?.find((item) => item.id === 'assistant-resource-view') const { assistants, isLoading: isAssistantsLoading, @@ -291,12 +292,13 @@ export function AssistantResourceList({ onAdd={onAddAssistant ?? (() => onStartDraftAssistant(null))} headerActions={ void setTopicDisplayMode(nextMode)} + onManageAssistants={manageAssistantsMenuItem?.onSelect} onOpenHistoryRecords={onOpenHistoryRecords} /> } - resourceMenuItems={resourceMenuItems} onSelect={handleSelect} onReorder={handleReorder} getContextMenuActions={getContextMenuActions} diff --git a/src/renderer/i18n/locales/en-us.json b/src/renderer/i18n/locales/en-us.json index 4d794736ed8..ed008c4a4a5 100644 --- a/src/renderer/i18n/locales/en-us.json +++ b/src/renderer/i18n/locales/en-us.json @@ -348,6 +348,9 @@ "failed": "Failed to list agents." } }, + "manage": { + "title": "Manage Agents" + }, "pin": { "title": "Pin Agent" }, diff --git a/src/renderer/i18n/locales/zh-cn.json b/src/renderer/i18n/locales/zh-cn.json index a49faa1572c..d7cda0943dd 100644 --- a/src/renderer/i18n/locales/zh-cn.json +++ b/src/renderer/i18n/locales/zh-cn.json @@ -348,6 +348,9 @@ "failed": "获取智能体列表失败" } }, + "manage": { + "title": "管理智能体" + }, "pin": { "title": "置顶智能体" }, diff --git a/src/renderer/i18n/locales/zh-tw.json b/src/renderer/i18n/locales/zh-tw.json index 8b022a4fc1c..85843a2c779 100644 --- a/src/renderer/i18n/locales/zh-tw.json +++ b/src/renderer/i18n/locales/zh-tw.json @@ -348,6 +348,9 @@ "failed": "無法列出 Agent。" } }, + "manage": { + "title": "管理智能體" + }, "pin": { "title": "圖釘代理" }, diff --git a/src/renderer/pages/agents/__tests__/AgentPage.test.tsx b/src/renderer/pages/agents/__tests__/AgentPage.test.tsx index 17fd7a0206c..a3de2655f4f 100644 --- a/src/renderer/pages/agents/__tests__/AgentPage.test.tsx +++ b/src/renderer/pages/agents/__tests__/AgentPage.test.tsx @@ -306,6 +306,7 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => ({ + 'agent.manage.title': '管理智能体', 'agent.session.list.title': '任务' })[key] ?? key }) @@ -500,7 +501,7 @@ vi.mock('../AgentSidePanel', () => ({ {resourceMenuItems?.map((item: { id: string; label: ReactNode; onSelect: () => void | Promise }) => ( ))}
@@ -529,7 +530,7 @@ vi.mock('@renderer/components/chat/resources/variants/AgentResourceList', () => {resourceMenuItems?.map((item) => ( ))}
@@ -643,7 +644,7 @@ describe('AgentPage', () => { activeSessionMocks.sessionSource = 'query' render() - fireEvent.click(screen.getByRole('button', { name: 'chat.resource_view.menu.agent' })) + fireEvent.click(screen.getByRole('button', { name: 'agent.manage.title' })) expect(screen.getByTestId('resource-catalog-agent')).toBeInTheDocument() expect(screen.getByTestId('agent-conversation-page-shell')).toBeInTheDocument() @@ -657,7 +658,7 @@ describe('AgentPage', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'chat.resource_view.menu.agent' })) + fireEvent.click(screen.getByRole('button', { name: 'agent.manage.title' })) fireEvent.click(screen.getByRole('button', { name: 'Open agent picker' })) expect(screen.getByTestId('agent-conversation-picker')).toBeInTheDocument() @@ -683,7 +684,7 @@ describe('AgentPage', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'chat.resource_view.menu.agent' })) + fireEvent.click(screen.getByRole('button', { name: 'agent.manage.title' })) fireEvent.click(screen.getByRole('button', { name: 'Open agent picker' })) fireEvent.click(screen.getByRole('button', { name: 'Select resource agent' })) @@ -725,7 +726,7 @@ describe('AgentPage', () => { activeSessionMocks.sessionSource = 'query' render() - fireEvent.click(screen.getByRole('button', { name: 'chat.resource_view.menu.agent' })) + fireEvent.click(screen.getByRole('button', { name: 'agent.manage.title' })) const shell = screen.getByTestId('agent-conversation-page-shell') expect(within(shell).getByTestId('resource-pane-open')).toHaveTextContent('true') diff --git a/src/renderer/pages/agents/components/Sessions.tsx b/src/renderer/pages/agents/components/Sessions.tsx index 1303c7710cf..9423474da54 100644 --- a/src/renderer/pages/agents/components/Sessions.tsx +++ b/src/renderer/pages/agents/components/Sessions.tsx @@ -2,7 +2,6 @@ import { Button, Tooltip } from '@cherrystudio/ui' import { loggerService } from '@logger' import { actionsToCommandMenuExtraItems } from '@renderer/components/chat/actions/actionMenuItems' import { - ConversationResourceMenu, type ConversationResourceMenuItem, remapResourceListCollapsedGroupIds, RESOURCE_LIST_RIGHT_PANEL_SEARCH_INPUT_CLASS, @@ -1363,6 +1362,7 @@ const Sessions = ({ ? 'empty' : 'idle' const hasActiveResourceMenuItem = resourceMenuItems?.some((item) => item.active) ?? false + const manageAgentsMenuItem = resourceMenuItems?.find((item) => item.id === 'agent-resource-view') return ( @@ -1420,8 +1420,10 @@ const Sessions = ({ onClick={handleHeaderCreateSession} actions={ void setSessionDisplayMode(nextMode)} + onManageAgents={manageAgentsMenuItem?.onSelect} onOpenHistoryRecords={onOpenHistoryRecords} sectionId={ displayMode === 'agent' @@ -1433,7 +1435,6 @@ const Sessions = ({ /> } /> - )} diff --git a/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx b/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx index 3c0bb52d189..c322b98f548 100644 --- a/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx +++ b/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx @@ -425,6 +425,7 @@ vi.mock('react-i18next', () => ({ 'agent.session.display.workdir': 'Work directory', 'agent.session.empty.description': 'Tasks will appear here after you start one.', 'agent.session.empty.title': 'No tasks yet', + 'agent.manage.title': 'Manage Agents', 'agent.edit.title': 'Edit Agent', 'agent.session.edit.title': 'Edit task', 'agent.session.file_manager.file_explorer': 'File Explorer', @@ -1117,6 +1118,7 @@ describe('Sessions', () => { it('clears session selection while a resource menu item is active', () => { cacheMocks.state.activeSessionId = 'session-a' + const onSelectResourceView = vi.fn() setupSessions({ sessions: [createSession({ id: 'session-a', name: 'Alpha session', orderKey: 'a' })] }) @@ -1126,15 +1128,18 @@ describe('Sessions', () => { resourceMenuItems={[ { active: true, - id: 'agent-skills', - label: 'Agent skills', - onSelect: vi.fn() + id: 'agent-resource-view', + label: 'Agents', + onSelect: onSelectResourceView } ]} /> ) - expect(screen.getByRole('button', { name: 'Agent skills' })).toHaveAttribute('aria-current', 'page') + expect(screen.queryByRole('button', { name: 'Manage Agents' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Display mode' })) + fireEvent.click(screen.getByRole('button', { name: 'Manage Agents' })) + expect(onSelectResourceView).toHaveBeenCalled() expect(screen.getByText('Alpha session').closest('[role="option"]')).not.toHaveAttribute('data-selected') }) diff --git a/src/renderer/pages/home/Tabs/components/Topics.tsx b/src/renderer/pages/home/Tabs/components/Topics.tsx index fdf3e54aa2f..e47040278d2 100644 --- a/src/renderer/pages/home/Tabs/components/Topics.tsx +++ b/src/renderer/pages/home/Tabs/components/Topics.tsx @@ -7,7 +7,6 @@ import { loggerService } from '@logger' import { actionsToCommandMenuExtraItems } from '@renderer/components/chat/actions/actionMenuItems' import { ResourceListActionContextMenu } from '@renderer/components/chat/actions/ResourceListActionContextMenu' import { - ConversationResourceMenu, type ConversationResourceMenuItem, RESOURCE_LIST_RIGHT_PANEL_SEARCH_INPUT_CLASS, ResourceList, @@ -639,6 +638,7 @@ export function Topics({ const visibleFilteredTopics = useMemo(() => (listLoading ? [] : filteredTopics), [filteredTopics, listLoading]) const listStatus = listError ? 'error' : listLoading ? 'loading' : filteredTopics.length === 0 ? 'empty' : 'idle' const hasActiveResourceMenuItem = resourceMenuItems?.some((item) => item.active) ?? false + const manageAssistantsMenuItem = resourceMenuItems?.find((item) => item.id === 'assistant-resource-view') const openAssistantEditor = useCallback((assistantId: string) => { setEditDialogTarget({ kind: 'assistant', id: assistantId }) }, []) @@ -1077,14 +1077,15 @@ export function Topics({ actions={ <> } /> - )} diff --git a/src/renderer/pages/home/Tabs/components/__tests__/Topics.test.tsx b/src/renderer/pages/home/Tabs/components/__tests__/Topics.test.tsx index d17974f87f1..050a98d34e3 100644 --- a/src/renderer/pages/home/Tabs/components/__tests__/Topics.test.tsx +++ b/src/renderer/pages/home/Tabs/components/__tests__/Topics.test.tsx @@ -257,6 +257,7 @@ vi.mock('react-i18next', () => ({ if (key === 'assistants.pin.title') return 'Pin Assistant' if (key === 'assistants.unpin.title') return 'Unpin Assistant' if (key === 'assistants.clear.menu_title') return 'Delete all assistant conversations' + if (key === 'assistants.presets.manage.title') return 'Manage Assistants' if (key === 'assistants.clear.title') return 'Clear conversations' if (key === 'assistants.clear.content') return 'Delete all assistant conversations?' if (key === 'chat.topics.clear.title') return 'Clear messages' @@ -2149,7 +2150,8 @@ describe('Topics', () => { expect(screen.getByRole('button', { name: 'Beta Assistant' })).toHaveAttribute('aria-expanded', 'false') }) - it('clears topic selection while a resource menu item is active', () => { + it('moves the assistant resource entry into the topic options menu', () => { + const onSelect = vi.fn() renderTopicList({ activeTopic: createRendererTopic({ id: 'topic-a', name: 'Alpha topic' }), resourceMenuItems: [ @@ -2158,12 +2160,25 @@ describe('Topics', () => { id: 'assistant-library', label: 'Assistant library', onSelect: vi.fn() + }, + { + id: 'assistant-resource-view', + label: 'Assistants', + onSelect } ] }) - expect(screen.getByRole('button', { name: 'Assistant library' })).toHaveAttribute('aria-current', 'page') + expect(screen.queryByRole('button', { name: 'Assistants' })).not.toBeInTheDocument() expect(getTopicRow('Alpha topic')).not.toHaveAttribute('data-selected') + + fireEvent.click(screen.getByLabelText('Display mode')) + const displayModeMenu = screen.getByText('Display mode').closest('[data-testid="menu-list"]') + expect(displayModeMenu).not.toBeNull() + + fireEvent.click(within(displayModeMenu as HTMLElement).getByRole('button', { name: 'Manage Assistants' })) + + expect(onSelect).toHaveBeenCalledTimes(1) }) it('opens the assistant group more menu from the group header context menu', () => { diff --git a/src/renderer/pages/home/__tests__/HomePage.test.tsx b/src/renderer/pages/home/__tests__/HomePage.test.tsx index 4080e0d7507..3ec431af9e2 100644 --- a/src/renderer/pages/home/__tests__/HomePage.test.tsx +++ b/src/renderer/pages/home/__tests__/HomePage.test.tsx @@ -468,11 +468,13 @@ vi.mock('../Tabs', () => ({ - {resourceMenuItems?.map((item: { id: string; label: ReactNode; onSelect: () => void | Promise }) => ( - - ))} + {resourceMenuItems + ?.filter((item: { id: string }) => item.id === 'assistant-resource-view') + .map((item: { id: string; onSelect: () => void | Promise }) => ( + + ))}
) })) @@ -543,11 +545,13 @@ vi.mock('@renderer/components/chat/resources/variants/AssistantResourceList', () - {resourceMenuItems?.map((item) => ( - - ))} + {resourceMenuItems + ?.filter((item) => item.id === 'assistant-resource-view') + .map((item) => ( + + ))}
) })) @@ -677,7 +681,7 @@ describe('HomePage', () => { it('renders the assistant resource view in the chat center', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'chat.resource_view.menu.assistant' })) + fireEvent.click(screen.getByRole('button', { name: 'assistants.presets.manage.title' })) expect(screen.getByTestId('resource-catalog-assistant')).toBeInTheDocument() expect(screen.getByTestId('home-conversation-page-shell')).toBeInTheDocument() @@ -689,7 +693,7 @@ describe('HomePage', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'chat.resource_view.menu.assistant' })) + fireEvent.click(screen.getByRole('button', { name: 'assistants.presets.manage.title' })) fireEvent.click(screen.getByRole('button', { name: 'Open assistant picker' })) expect(screen.getByTestId('assistant-conversation-picker')).toBeInTheDocument() @@ -708,7 +712,7 @@ describe('HomePage', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'chat.resource_view.menu.assistant' })) + fireEvent.click(screen.getByRole('button', { name: 'assistants.presets.manage.title' })) fireEvent.click(screen.getByRole('button', { name: 'Open assistant picker' })) fireEvent.click(screen.getByRole('button', { name: 'Select my assistant' })) @@ -731,7 +735,7 @@ describe('HomePage', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'chat.resource_view.menu.assistant' })) + fireEvent.click(screen.getByRole('button', { name: 'assistants.presets.manage.title' })) const shell = screen.getByTestId('home-conversation-page-shell') expect(within(shell).getByTestId('pane-open')).toHaveTextContent('true') @@ -751,7 +755,7 @@ describe('HomePage', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'chat.resource_view.menu.assistant' })) + fireEvent.click(screen.getByRole('button', { name: 'assistants.presets.manage.title' })) fireEvent.click(screen.getByRole('button', { name: 'Go to chat with assistant 2' })) await waitFor(() => @@ -768,7 +772,7 @@ describe('HomePage', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'chat.resource_view.menu.assistant' })) + fireEvent.click(screen.getByRole('button', { name: 'assistants.presets.manage.title' })) fireEvent.click(screen.getByRole('button', { name: 'Go to chat with assistant 2' })) await waitFor(() => expect(homeMocks.createTopic).toHaveBeenCalledWith({ assistantId: 'assistant-2' })) From a8b5d6d6673d3a1036f0b04b14887e950e87fb81 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sat, 4 Jul 2026 01:44:50 +0800 Subject: [PATCH 05/40] refactor: enhance shell actions and minimize functionality across components --- .../components/chat/panes/ArtifactPane.tsx | 2 +- .../components/chat/panes/Shell/Shell.tsx | 17 +++++++--- .../chat/panes/Shell/__tests__/Shell.test.tsx | 33 +++++++++++++++++++ .../components/chat/panes/Shell/index.ts | 2 +- .../chat/resources/ResourceList.tsx | 2 +- .../chat/shell/ConversationStageCenter.tsx | 8 ++++- .../components/chat/shell/RightPaneHost.tsx | 2 +- .../ConversationStageCenter.test.tsx | 15 +++++++-- .../composer/ConversationComposerStage.tsx | 7 ++-- .../ConversationComposerStage.test.tsx | 13 ++++++++ src/renderer/pages/agents/AgentPage.tsx | 4 +++ .../pages/agents/__tests__/AgentPage.test.tsx | 17 ++++++++-- .../AgentRightPane/AgentRightPane.tsx | 2 +- .../pages/agents/components/SessionItem.tsx | 6 +++- src/renderer/pages/home/HomePage.tsx | 4 +++ .../pages/home/Tabs/components/Topics.tsx | 13 ++++++++ src/renderer/pages/home/Tabs/index.tsx | 3 ++ .../pages/home/__tests__/HomePage.test.tsx | 28 +++++++++++++++- .../home/components/TopicBranchPanel.tsx | 2 +- 19 files changed, 161 insertions(+), 19 deletions(-) diff --git a/src/renderer/components/chat/panes/ArtifactPane.tsx b/src/renderer/components/chat/panes/ArtifactPane.tsx index a8dd1056f8e..aacdab0c325 100644 --- a/src/renderer/components/chat/panes/ArtifactPane.tsx +++ b/src/renderer/components/chat/panes/ArtifactPane.tsx @@ -613,7 +613,7 @@ export function ArtifactPaneView({
diff --git a/src/renderer/components/chat/panes/Shell/Shell.tsx b/src/renderer/components/chat/panes/Shell/Shell.tsx index 2e628d744b6..a2827a8e155 100644 --- a/src/renderer/components/chat/panes/Shell/Shell.tsx +++ b/src/renderer/components/chat/panes/Shell/Shell.tsx @@ -35,9 +35,10 @@ export interface ShellState { pdfLayoutRefreshKey: number } -interface ShellActions { +export interface ShellActions { close: (afterClose?: () => void) => void finishClose: () => void + minimize: () => void openTab: (tab: string) => void toggleMaximized: () => void refreshPdfLayout: () => void @@ -64,6 +65,10 @@ export function useShellActions(): ShellActions { return actions } +export function useOptionalShellActions(): ShellActions | undefined { + return use(ShellActionsContext) ?? undefined +} + export function useShellState(): ShellState { const state = use(ShellStateContext) if (!state) throw new Error('useShellState must be used within ') @@ -136,6 +141,10 @@ function ShellProvider({ }) onOpenChangeRef.current?.(true) }, []) + const minimize = useCallback(() => { + setPdfLayoutPending(false) + setMaximized(false) + }, []) const toggleMaximized = useCallback(() => { setPdfLayoutPending(false) setMaximized((currentMaximized) => !currentMaximized) @@ -150,8 +159,8 @@ function ShellProvider({ [activeTab, maximized, open, pdfLayoutPending, pdfLayoutRefreshKey] ) const actions = useMemo( - () => ({ close, finishClose, openTab, toggleMaximized, refreshPdfLayout }), - [close, finishClose, openTab, refreshPdfLayout, toggleMaximized] + () => ({ close, finishClose, minimize, openTab, toggleMaximized, refreshPdfLayout }), + [close, finishClose, minimize, openTab, refreshPdfLayout, toggleMaximized] ) return ( @@ -352,7 +361,7 @@ function ShellTabs({ children }: { children: ReactNode }) { value={state.activeTab} onValueChange={actions.openTab} variant="line" - className="h-full gap-0 overflow-hidden bg-card text-card-foreground"> + className="h-full gap-0 overflow-hidden text-card-foreground"> {children} ) diff --git a/src/renderer/components/chat/panes/Shell/__tests__/Shell.test.tsx b/src/renderer/components/chat/panes/Shell/__tests__/Shell.test.tsx index d144f6d4b48..d314b63325d 100644 --- a/src/renderer/components/chat/panes/Shell/__tests__/Shell.test.tsx +++ b/src/renderer/components/chat/panes/Shell/__tests__/Shell.test.tsx @@ -207,6 +207,16 @@ function ShellStateSnapshot() { ) } +function ShellMinimizeButton() { + const actions = useShellActions() + + return ( + + ) +} + function triggerRightSidebarShortcut() { const handler = shortcutHandlers.get('topic.sidebar.toggle') if (!handler) throw new Error('Expected right sidebar shortcut to be registered') @@ -382,6 +392,29 @@ describe('Shell.Toggle', () => { expect(screen.getByTestId('shell-state')).toHaveTextContent('closed:files:false') }) + it('minimizes from maximized mode without closing the pane', () => { + render( + + + + + Files + + + + + + ) + + triggerRightSidebarShortcut() + fireEvent.click(screen.getByRole('button', { name: 'common.maximize' })) + expect(screen.getByTestId('shell-state')).toHaveTextContent('open:files:true') + + fireEvent.click(screen.getByRole('button', { name: 'minimize shell' })) + + expect(screen.getByTestId('shell-state')).toHaveTextContent('open:files:false') + }) + it('does not respond to the right sidebar shortcut when disabled', () => { render( diff --git a/src/renderer/components/chat/panes/Shell/index.ts b/src/renderer/components/chat/panes/Shell/index.ts index 21d6096df03..c967a57dd6d 100644 --- a/src/renderer/components/chat/panes/Shell/index.ts +++ b/src/renderer/components/chat/panes/Shell/index.ts @@ -8,4 +8,4 @@ export { useResourcePane } from './resourcePane' export { ResourcePaneCountButton, type ResourcePaneCountButtonProps } from './ResourcePaneCountButton' -export { Shell, useOptionalShellState, useShellActions, useShellState } from './Shell' +export { Shell, useOptionalShellActions, useOptionalShellState, useShellActions, useShellState } from './Shell' diff --git a/src/renderer/components/chat/resources/ResourceList.tsx b/src/renderer/components/chat/resources/ResourceList.tsx index 7dc66df76ad..f5f8b95b1d9 100644 --- a/src/renderer/components/chat/resources/ResourceList.tsx +++ b/src/renderer/components/chat/resources/ResourceList.tsx @@ -119,7 +119,7 @@ type HeaderProps = ComponentProps<'div'> & { function Header({ actions, children, className, count, icon, ref, title, ...props }: HeaderProps) { return ( -
+
{(title || actions) && (
{icon && ( diff --git a/src/renderer/components/chat/shell/ConversationStageCenter.tsx b/src/renderer/components/chat/shell/ConversationStageCenter.tsx index 6e225c4eae7..f9ba28b1404 100644 --- a/src/renderer/components/chat/shell/ConversationStageCenter.tsx +++ b/src/renderer/components/chat/shell/ConversationStageCenter.tsx @@ -7,10 +7,16 @@ export type ConversationStageCenterProps = ComponentProps - +
) } diff --git a/src/renderer/components/chat/shell/RightPaneHost.tsx b/src/renderer/components/chat/shell/RightPaneHost.tsx index 82473abb1e1..369ff625f5d 100644 --- a/src/renderer/components/chat/shell/RightPaneHost.tsx +++ b/src/renderer/components/chat/shell/RightPaneHost.tsx @@ -157,7 +157,7 @@ export function RightPaneHost({ data-resizing={isResizing || undefined} className={cn( 'group/right-pane h-full min-h-0 shrink-0 overflow-hidden', - resizable && 'relative bg-card [border-left:0.5px_solid_var(--color-border)]', + resizable && 'relative [border-left:0.5px_solid_var(--color-border)]', className )} style={constrainedStyle}> diff --git a/src/renderer/components/chat/shell/__tests__/ConversationStageCenter.test.tsx b/src/renderer/components/chat/shell/__tests__/ConversationStageCenter.test.tsx index 7a77383604f..2358904740e 100644 --- a/src/renderer/components/chat/shell/__tests__/ConversationStageCenter.test.tsx +++ b/src/renderer/components/chat/shell/__tests__/ConversationStageCenter.test.tsx @@ -14,15 +14,17 @@ interface MockStageProps { composer: ReactNode homeWelcomeText?: string composerElevated?: boolean + mainVisible?: boolean } vi.mock('@renderer/components/composer/ConversationComposerStage', () => ({ - default: ({ placement, main, composer, homeWelcomeText, composerElevated }: MockStageProps) => ( + default: ({ placement, main, composer, homeWelcomeText, composerElevated, mainVisible }: MockStageProps) => (
+ data-composer-elevated={String(Boolean(composerElevated))} + data-main-visible={String(Boolean(mainVisible))}>
{main}
{composer}
@@ -60,4 +62,13 @@ describe('ConversationStageCenter', () => { expect(screen.getByTestId('conversation-stage')).toHaveAttribute('data-composer-elevated', 'true') }) + + it('hides the main message area when an optional right pane shell is maximized', () => { + optionalShellState.value = { maximized: true } + + render(messages
} composer={
} />) + + expect(screen.getByTestId('conversation-stage')).toHaveAttribute('data-main-visible', 'false') + expect(screen.getByTestId('stage-main')).toHaveTextContent('messages') + }) }) diff --git a/src/renderer/components/composer/ConversationComposerStage.tsx b/src/renderer/components/composer/ConversationComposerStage.tsx index 09f668ddb58..d7c1d5f8144 100644 --- a/src/renderer/components/composer/ConversationComposerStage.tsx +++ b/src/renderer/components/composer/ConversationComposerStage.tsx @@ -12,6 +12,7 @@ interface ConversationComposerStageProps { homeWelcomeText?: string overlay?: ReactNode composerElevated?: boolean + mainVisible?: boolean } export default function ConversationComposerStage({ @@ -20,9 +21,11 @@ export default function ConversationComposerStage({ composer, homeWelcomeText, overlay, - composerElevated + composerElevated, + mainVisible }: ConversationComposerStageProps) { const isDocked = placement === 'docked' + const resolvedMainVisible = mainVisible ?? isDocked return ( : undefined} - mainVisible={isDocked} + mainVisible={resolvedMainVisible} overlay={overlay} composerElevated={composerElevated} /> diff --git a/src/renderer/components/composer/__tests__/ConversationComposerStage.test.tsx b/src/renderer/components/composer/__tests__/ConversationComposerStage.test.tsx index 21e77567b84..2725f8cfc98 100644 --- a/src/renderer/components/composer/__tests__/ConversationComposerStage.test.tsx +++ b/src/renderer/components/composer/__tests__/ConversationComposerStage.test.tsx @@ -56,4 +56,17 @@ describe('ConversationComposerStage', () => { expect(frameProps.current?.mainVisible).toBe(true) expect(screen.getByTestId('stage-home-header')).toBeEmptyDOMElement() }) + + it('allows callers to hide main content in docked placement', () => { + render( + messages
} + composer={
composer
} + mainVisible={false} + /> + ) + + expect(frameProps.current?.mainVisible).toBe(false) + }) }) diff --git a/src/renderer/pages/agents/AgentPage.tsx b/src/renderer/pages/agents/AgentPage.tsx index 4b6e5ca405a..c91fdecfb22 100644 --- a/src/renderer/pages/agents/AgentPage.tsx +++ b/src/renderer/pages/agents/AgentPage.tsx @@ -704,6 +704,9 @@ const AgentPage = () => { }, [closeResourceView, conversationNav, currentTabId, setActiveSessionAndDiscardDraft] ) + const collapseSidebarAfterSessionSelect = useCallback(() => { + if (effectiveShowSidebar) setResourceListOpen(false) + }, [effectiveShowSidebar, setResourceListOpen]) // Classic-layout reset after deleting the active agent: select the latest remaining // session (across other agents), or clear to the empty state. Never open the // draft compose — that belongs to the modern layout. Filter by the deleted id so @@ -955,6 +958,7 @@ const AgentPage = () => { activeSessionId={activeSessionId} revealRequest={sessionRevealRequest} onOpenHistoryRecords={openHistoryRecords} + onSelectItem={collapseSidebarAfterSessionSelect} onStartDraftSession={startDraftSession} onStartMissingAgentDraft={isMessageOnlyView ? undefined : startMissingAgentDraft} resourceMenuItems={resourceMenuItems} diff --git a/src/renderer/pages/agents/__tests__/AgentPage.test.tsx b/src/renderer/pages/agents/__tests__/AgentPage.test.tsx index a3de2655f4f..379570c2ba1 100644 --- a/src/renderer/pages/agents/__tests__/AgentPage.test.tsx +++ b/src/renderer/pages/agents/__tests__/AgentPage.test.tsx @@ -458,6 +458,7 @@ vi.mock('../AgentSidePanel', () => ({ default: ({ activeSessionId, onOpenHistoryRecords, + onSelectItem, onStartDraftSession, onStartMissingAgentDraft, revealRequest, @@ -471,7 +472,7 @@ vi.mock('../AgentSidePanel', () => ({ data-testid="agent-side-panel"> @@ -1146,6 +1161,17 @@ describe('HomePage', () => { expect(screen.getByTestId('pane-open')).toHaveTextContent('false') }) + it('collapses the topic sidebar after selecting a topic from the sidebar', async () => { + homeMocks.preferenceValues.set('topic.tab.show', true) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Select topic next' })) + + await waitFor(() => expect(homeMocks.setShowSidebar).toHaveBeenCalledWith(false)) + expect(screen.getByTestId('pane-open')).toHaveTextContent('false') + }) + it('starts a draft assistant selection when history clears the selected topic', async () => { render() diff --git a/src/renderer/pages/home/components/TopicBranchPanel.tsx b/src/renderer/pages/home/components/TopicBranchPanel.tsx index a472708c1ae..ef3016eb20e 100644 --- a/src/renderer/pages/home/components/TopicBranchPanel.tsx +++ b/src/renderer/pages/home/components/TopicBranchPanel.tsx @@ -236,7 +236,7 @@ const TopicBranchPanel: FC = ({ }, []) return ( -
+
{topicName && ( <> From 9997b276786c4019c8a229675bf8c1fcb4bbce92 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sat, 4 Jul 2026 01:58:23 +0800 Subject: [PATCH 06/40] refactor: enhance SectionHeader styling and add collapse affordance to ResourceEntityRail test --- .../chat/resources/ResourceListGroups.tsx | 17 +++++++++--- .../__tests__/ResourceEntityRail.test.tsx | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/chat/resources/ResourceListGroups.tsx b/src/renderer/components/chat/resources/ResourceListGroups.tsx index a042098ef5d..45820c6d2b3 100644 --- a/src/renderer/components/chat/resources/ResourceListGroups.tsx +++ b/src/renderer/components/chat/resources/ResourceListGroups.tsx @@ -64,20 +64,31 @@ export function SectionHeader({ section, className, ref, style, ...props }: Sect ref={ref} style={style} className={cn( - 'group/resource-list-section flex w-full items-end px-0.5 pb-[2px]', + 'group/resource-list-section flex w-full items-center text-foreground text-sm', RESOURCE_LIST_ROW_HEIGHT_CLASS, className )} {...props}> -
+
{sectionHeaderAction && (
{ expect(screen.getByRole('listbox', { name: 'Assistants list' })).toHaveAttribute('data-draggable', 'false') }) + it('renders tag section headers with the shared hover and collapse affordance', () => { + render( + , tag: 'work' }, + { id: 'home-a', name: 'Home A', icon: , tag: 'home' } + ]} + variant="assistant" + onAdd={vi.fn()} + onSelect={vi.fn()} + /> + ) + + const workHeader = screen.getByRole('button', { name: 'work' }) + const visualRow = workHeader.closest('div') + + expect(visualRow).toHaveClass('hover:bg-sidebar-accent', 'rounded-lg') + expect(workHeader.querySelector('svg')).not.toBeNull() + + fireEvent.click(workHeader) + expect(workHeader).toHaveAttribute('aria-expanded', 'false') + }) + it('keeps a real tag named like the untagged sentinel separate from untagged entities', () => { render( Date: Sat, 4 Jul 2026 02:04:16 +0800 Subject: [PATCH 07/40] refactor: remove onSelectItem prop from various components and update related tests to maintain sidebar behavior --- src/renderer/pages/agents/AgentPage.tsx | 5 ----- src/renderer/pages/agents/AgentSidePanel.tsx | 3 --- src/renderer/pages/agents/__tests__/AgentPage.test.tsx | 9 ++++----- src/renderer/pages/agents/components/SessionItem.tsx | 5 +---- src/renderer/pages/agents/components/Sessions.tsx | 7 ------- src/renderer/pages/home/HomePage.tsx | 4 ---- src/renderer/pages/home/Tabs/components/Topics.tsx | 9 --------- src/renderer/pages/home/Tabs/index.tsx | 3 --- src/renderer/pages/home/__tests__/HomePage.test.tsx | 10 +++++----- 9 files changed, 10 insertions(+), 45 deletions(-) diff --git a/src/renderer/pages/agents/AgentPage.tsx b/src/renderer/pages/agents/AgentPage.tsx index c91fdecfb22..5c91b0d43d2 100644 --- a/src/renderer/pages/agents/AgentPage.tsx +++ b/src/renderer/pages/agents/AgentPage.tsx @@ -704,9 +704,6 @@ const AgentPage = () => { }, [closeResourceView, conversationNav, currentTabId, setActiveSessionAndDiscardDraft] ) - const collapseSidebarAfterSessionSelect = useCallback(() => { - if (effectiveShowSidebar) setResourceListOpen(false) - }, [effectiveShowSidebar, setResourceListOpen]) // Classic-layout reset after deleting the active agent: select the latest remaining // session (across other agents), or clear to the empty state. Never open the // draft compose — that belongs to the modern layout. Filter by the deleted id so @@ -958,7 +955,6 @@ const AgentPage = () => { activeSessionId={activeSessionId} revealRequest={sessionRevealRequest} onOpenHistoryRecords={openHistoryRecords} - onSelectItem={collapseSidebarAfterSessionSelect} onStartDraftSession={startDraftSession} onStartMissingAgentDraft={isMessageOnlyView ? undefined : startMissingAgentDraft} resourceMenuItems={resourceMenuItems} @@ -977,7 +973,6 @@ const AgentPage = () => { activeSessionId={activeSessionId} agentIdFilter={activeResourceAgentId} revealRequest={sessionRevealRequest} - onSelectItem={undefined} onStartDraftSession={startDraftSession} onStartMissingAgentDraft={isMessageOnlyView ? undefined : startMissingAgentDraft} setActiveSessionId={setActiveSessionAndDiscardDraft} diff --git a/src/renderer/pages/agents/AgentSidePanel.tsx b/src/renderer/pages/agents/AgentSidePanel.tsx index 1f15ac1e1d0..53e7d9947de 100644 --- a/src/renderer/pages/agents/AgentSidePanel.tsx +++ b/src/renderer/pages/agents/AgentSidePanel.tsx @@ -7,7 +7,6 @@ import type { DraftAgentSessionDefaults } from './types' interface AgentSidePanelProps { activeSessionId: string | null onOpenHistoryRecords?: () => void - onSelectItem?: () => void onStartDraftSession?: (defaults: DraftAgentSessionDefaults) => void | Promise onStartMissingAgentDraft?: () => void | Promise revealRequest?: ResourceListRevealRequest @@ -18,7 +17,6 @@ interface AgentSidePanelProps { const AgentSidePanel = ({ activeSessionId, onOpenHistoryRecords, - onSelectItem, onStartDraftSession, onStartMissingAgentDraft, revealRequest, @@ -36,7 +34,6 @@ const AgentSidePanel = ({ ({ default: ({ activeSessionId, onOpenHistoryRecords, - onSelectItem, onStartDraftSession, onStartMissingAgentDraft, revealRequest, @@ -484,7 +483,6 @@ vi.mock('../AgentSidePanel', () => ({ createdAt: '2026-01-02T00:00:00.000Z', updatedAt: '2026-01-02T00:00:00.000Z' }) - onSelectItem?.() }}> Select session next @@ -1351,15 +1349,16 @@ describe('AgentPage', () => { expect(screen.getByTestId('pane-open')).toHaveTextContent('false') }) - it('collapses the agent sidebar after selecting a session from the sidebar', async () => { + it('keeps the agent sidebar open after selecting a session from the sidebar', async () => { agentPageMocks.showSidebar = true render() fireEvent.click(screen.getByRole('button', { name: 'Select session next' })) - await waitFor(() => expect(agentPageMocks.setShowSidebar).toHaveBeenCalledWith(false)) - expect(screen.getByTestId('pane-open')).toHaveTextContent('false') + await waitFor(() => expect(agentPageMocks.activeSessionOptions?.activeSessionId).toBe('session-next')) + expect(agentPageMocks.setShowSidebar).not.toHaveBeenCalledWith(false) + expect(screen.getByTestId('pane-open')).toHaveTextContent('true') }) it('removes the session sidebar entirely in a detached agent window, shortcut included', () => { diff --git a/src/renderer/pages/agents/components/SessionItem.tsx b/src/renderer/pages/agents/components/SessionItem.tsx index cc1fcc90267..d60c2190de3 100644 --- a/src/renderer/pages/agents/components/SessionItem.tsx +++ b/src/renderer/pages/agents/components/SessionItem.tsx @@ -25,7 +25,6 @@ interface SessionItemProps { onOpenInNewTab?: (session: AgentSessionEntity) => void onOpenInNewWindow?: (session: AgentSessionEntity) => void onPress: (id: string) => void - onSelectItem?: () => void onTogglePin?: (id: string) => void | Promise pinned?: boolean reserveLeadingIconSlot?: boolean @@ -39,7 +38,6 @@ const SessionItem = ({ onOpenInNewTab, onOpenInNewWindow, onPress, - onSelectItem, onTogglePin, pinned = false, reserveLeadingIconSlot = true, @@ -167,9 +165,8 @@ const SessionItem = ({ } if (shellState?.maximized) shellActions?.minimize() onPress(session.id) - onSelectItem?.() }, - [active, handleOpenInNewTab, onOpenInNewTab, onPress, onSelectItem, session.id, shellActions, shellState?.maximized] + [active, handleOpenInNewTab, onOpenInNewTab, onPress, session.id, shellActions, shellState?.maximized] ) const handleAuxClick = useCallback( diff --git a/src/renderer/pages/agents/components/Sessions.tsx b/src/renderer/pages/agents/components/Sessions.tsx index 9423474da54..1bc5baaadae 100644 --- a/src/renderer/pages/agents/components/Sessions.tsx +++ b/src/renderer/pages/agents/components/Sessions.tsx @@ -79,7 +79,6 @@ import { type SessionsBaseProps = { agentIdFilter?: string | null onOpenHistoryRecords?: () => void - onSelectItem?: () => void onStartDraftSession?: (defaults: DraftAgentSessionDefaults) => void | Promise onStartMissingAgentDraft?: () => void | Promise presentation?: 'sidebar' | 'right-panel' @@ -247,7 +246,6 @@ const Sessions = ({ activeSessionId, agentIdFilter, onOpenHistoryRecords, - onSelectItem, onStartDraftSession, onStartMissingAgentDraft, presentation = 'sidebar', @@ -1451,7 +1449,6 @@ const Sessions = ({ onOpenInNewTab={openSessionInNewTab} onOpenInNewWindow={openSessionInNewWindow} onRetry={handleRetry} - onSelectItem={onSelectItem} onTogglePin={togglePin} setActiveSessionId={handleSelectSession} /> @@ -1491,7 +1488,6 @@ interface SessionListBodyProps { onOpenInNewTab?: (session: AgentSessionEntity) => void onOpenInNewWindow?: (session: AgentSessionEntity) => void onRetry: () => Promise - onSelectItem?: () => void onTogglePin: (id: string) => void | Promise setActiveSessionId: (id: string | null) => void } @@ -1509,7 +1505,6 @@ function SessionListBody({ onOpenInNewTab, onOpenInNewWindow, onRetry, - onSelectItem, onTogglePin, setActiveSessionId }: SessionListBodyProps) { @@ -1531,7 +1526,6 @@ function SessionListBody({ onOpenInNewTab={onOpenInNewTab} onOpenInNewWindow={onOpenInNewWindow} onPress={setActiveSessionId} - onSelectItem={onSelectItem} /> ), [ @@ -1541,7 +1535,6 @@ function SessionListBody({ onDeleteSession, onOpenInNewTab, onOpenInNewWindow, - onSelectItem, onTogglePin, setActiveSessionId ] diff --git a/src/renderer/pages/home/HomePage.tsx b/src/renderer/pages/home/HomePage.tsx index d61ca9fde37..8b74894f9a4 100644 --- a/src/renderer/pages/home/HomePage.tsx +++ b/src/renderer/pages/home/HomePage.tsx @@ -677,9 +677,6 @@ const HomePage: FC = () => { const handleGlobalSearchTopicSelect = useEffectEvent((topic: Topic, messageId?: string) => { handleHistoryTopicSelect(topic, messageId) }) - const collapseSidebarAfterTopicSelect = useCallback(() => { - if (effectiveShowSidebar) setResourceListOpen(false) - }, [effectiveShowSidebar, setResourceListOpen]) useEffect(() => { const unsubscribe = EventEmitter.on(EVENT_NAMES.GLOBAL_SEARCH_SELECT_TOPIC, (topic) => { @@ -772,7 +769,6 @@ const HomePage: FC = () => { setActiveTopic={setActiveTopicAndDiscardDraft} onNewTopic={isMessageOnlyView ? undefined : startDraftAssistantSelection} onOpenHistoryRecords={openHistoryRecords} - onSelectItem={collapseSidebarAfterTopicSelect} revealRequest={topicRevealRequest} resourceMenuItems={resourceMenuItems} /> diff --git a/src/renderer/pages/home/Tabs/components/Topics.tsx b/src/renderer/pages/home/Tabs/components/Topics.tsx index 5e320be01d2..99eda6412a5 100644 --- a/src/renderer/pages/home/Tabs/components/Topics.tsx +++ b/src/renderer/pages/home/Tabs/components/Topics.tsx @@ -90,7 +90,6 @@ interface Props { assistantIdFilter?: string | null onNewTopic?: (payload?: AddNewTopicPayload) => void | Promise onOpenHistoryRecords?: () => void - onSelectItem?: () => void presentation?: 'sidebar' | 'right-panel' revealRequest?: ResourceListRevealRequest resourceMenuItems?: readonly ConversationResourceMenuItem[] @@ -191,7 +190,6 @@ export function Topics({ assistantIdFilter, onNewTopic, onOpenHistoryRecords, - onSelectItem, presentation = 'sidebar', revealRequest, resourceMenuItems, @@ -1112,7 +1110,6 @@ export function Topics({ onOpenInNewWindow={tabs ? openTopicInNewWindow : undefined} onPinTopic={handlePinTopic} onRequestTopicImageAction={handleTopicImageAction} - onSelectItem={onSelectItem} onSwitchTopic={setActiveTopic} topicsLength={topics.length} variant={isAssistantDisplayMode && !isRightPanel ? 'draggable' : 'plain'} @@ -1224,7 +1221,6 @@ interface TopicListBodyProps { onOpenInNewWindow?: (topic: Topic) => void onPinTopic: (topic: Topic) => Promise onRequestTopicImageAction: (type: TopicImageActionType, topic: Topic) => void - onSelectItem?: () => void onSwitchTopic: (topic: Topic) => void topicsLength: number variant: TopicListBodyVariant @@ -1253,7 +1249,6 @@ function TopicListBody(props: TopicListBodyProps) { onOpenInNewWindow, onPinTopic, onRequestTopicImageAction, - onSelectItem, onSwitchTopic, topicsLength, variant @@ -1278,7 +1273,6 @@ function TopicListBody(props: TopicListBodyProps) { onOpenInNewWindow, onPinTopic, onRequestTopicImageAction, - onSelectItem, onSwitchTopic, topicsLength }), @@ -1300,7 +1294,6 @@ function TopicListBody(props: TopicListBodyProps) { onOpenInNewWindow, onPinTopic, onRequestTopicImageAction, - onSelectItem, onSwitchTopic, topicsLength ] @@ -1352,7 +1345,6 @@ function TopicRow({ onOpenInNewWindow, onPinTopic, onRequestTopicImageAction, - onSelectItem, onSwitchTopic, topic, topicsLength @@ -1424,7 +1416,6 @@ function TopicRow({ onClick={() => { if (shellState?.maximized) shellActions?.minimize() onSwitchTopic(topic) - onSelectItem?.() }}> {showLeadingSlot && } void | Promise onOpenHistoryRecords?: () => void - onSelectItem?: () => void setActiveTopic: (topic: Topic) => void revealRequest?: ResourceListRevealRequest resourceMenuItems?: readonly ConversationResourceMenuItem[] @@ -21,7 +20,6 @@ const HomeTabs: FC = ({ activeTopic, onNewTopic, onOpenHistoryRecords, - onSelectItem, setActiveTopic, revealRequest, resourceMenuItems, @@ -35,7 +33,6 @@ const HomeTabs: FC = ({ setActiveTopic={setActiveTopic} onNewTopic={onNewTopic} onOpenHistoryRecords={onOpenHistoryRecords} - onSelectItem={onSelectItem} revealRequest={revealRequest} resourceMenuItems={resourceMenuItems} /> diff --git a/src/renderer/pages/home/__tests__/HomePage.test.tsx b/src/renderer/pages/home/__tests__/HomePage.test.tsx index a08388a3650..3231ba07e9d 100644 --- a/src/renderer/pages/home/__tests__/HomePage.test.tsx +++ b/src/renderer/pages/home/__tests__/HomePage.test.tsx @@ -463,7 +463,7 @@ vi.mock('../components/ChatNavbar', () => ({ })) vi.mock('../Tabs', () => ({ - default: ({ onOpenHistoryRecords, onSelectItem, resourceMenuItems, revealRequest, setActiveTopic }: any) => ( + default: ({ onOpenHistoryRecords, resourceMenuItems, revealRequest, setActiveTopic }: any) => (
@@ -1161,15 +1160,16 @@ describe('HomePage', () => { expect(screen.getByTestId('pane-open')).toHaveTextContent('false') }) - it('collapses the topic sidebar after selecting a topic from the sidebar', async () => { + it('keeps the topic sidebar open after selecting a topic from the sidebar', async () => { homeMocks.preferenceValues.set('topic.tab.show', true) render() fireEvent.click(screen.getByRole('button', { name: 'Select topic next' })) - await waitFor(() => expect(homeMocks.setShowSidebar).toHaveBeenCalledWith(false)) - expect(screen.getByTestId('pane-open')).toHaveTextContent('false') + await waitFor(() => expect(screen.getByTestId('active-topic')).toHaveTextContent('topic-next')) + expect(homeMocks.setShowSidebar).not.toHaveBeenCalledWith(false) + expect(screen.getByTestId('pane-open')).toHaveTextContent('true') }) it('starts a draft assistant selection when history clears the selected topic', async () => { From b6ca36820d731063fa9a79f5529d1116d623e9c4 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sat, 4 Jul 2026 03:22:07 +0800 Subject: [PATCH 08/40] refactor: add onSelectedClick handlers to ResourceEntityRail, AgentResourceList, and AssistantResourceList components --- .../components/chat/panes/Shell/Shell.tsx | 16 ++++++ .../chat/panes/Shell/__tests__/Shell.test.tsx | 35 +++++++++++++ .../chat/resources/ResourceListVirtual.tsx | 2 +- .../resources/variants/AgentResourceList.tsx | 4 ++ .../variants/AssistantResourceList.tsx | 4 ++ .../resources/variants/ResourceEntityRail.tsx | 22 ++++++-- .../__tests__/ResourceEntityRail.test.tsx | 50 +++++++++++++++++++ src/renderer/pages/agents/AgentPage.tsx | 13 +++++ .../pages/agents/__tests__/AgentPage.test.tsx | 30 +++++++++-- src/renderer/pages/home/HomePage.tsx | 1 + .../pages/home/__tests__/HomePage.test.tsx | 15 ++++++ 11 files changed, 184 insertions(+), 8 deletions(-) diff --git a/src/renderer/components/chat/panes/Shell/Shell.tsx b/src/renderer/components/chat/panes/Shell/Shell.tsx index a2827a8e155..ced655687f6 100644 --- a/src/renderer/components/chat/panes/Shell/Shell.tsx +++ b/src/renderer/components/chat/panes/Shell/Shell.tsx @@ -118,6 +118,22 @@ function ShellProvider({ closeCallbacksRef.current = [] for (const callback of callbacks) callback() }, []) + + useEffect(() => { + if (openRef.current === defaultOpen) return + + openRef.current = defaultOpen + setOpen(defaultOpen) + if (defaultOpen) { + setActiveTab(defaultTab) + setPdfLayoutPending(true) + } else { + setMaximized(false) + setPdfLayoutPending(false) + finishClose() + } + }, [defaultOpen, defaultTab, finishClose]) + const close = useCallback((afterClose?: () => void) => { if (!openRef.current) { afterClose?.() diff --git a/src/renderer/components/chat/panes/Shell/__tests__/Shell.test.tsx b/src/renderer/components/chat/panes/Shell/__tests__/Shell.test.tsx index d314b63325d..0cb0cdc6912 100644 --- a/src/renderer/components/chat/panes/Shell/__tests__/Shell.test.tsx +++ b/src/renderer/components/chat/panes/Shell/__tests__/Shell.test.tsx @@ -336,6 +336,41 @@ describe('Shell.Toggle', () => { expect(screen.getByRole('button', { name: 'common.close_sidebar' })).toHaveAttribute('data-state', 'open') }) + it('syncs when the owning component changes the default open state', () => { + const { rerender } = render( + + + + + + ) + + expect(screen.getByTestId('shell-state')).toHaveTextContent('open:files:false') + + fireEvent.click(screen.getByRole('button', { name: 'open trace' })) + expect(screen.getByTestId('shell-state')).toHaveTextContent('open:trace:false') + + rerender( + + + + + + ) + + expect(screen.getByTestId('shell-state')).toHaveTextContent('closed:trace:false') + + rerender( + + + + + + ) + + expect(screen.getByTestId('shell-state')).toHaveTextContent('open:files:false') + }) + it('does not rerender actions-only consumers when shell state changes', () => { render( diff --git a/src/renderer/components/chat/resources/ResourceListVirtual.tsx b/src/renderer/components/chat/resources/ResourceListVirtual.tsx index 78319baf394..3f2b470d313 100644 --- a/src/renderer/components/chat/resources/ResourceListVirtual.tsx +++ b/src/renderer/components/chat/resources/ResourceListVirtual.tsx @@ -153,7 +153,7 @@ function useAutoHideScrollbar(delay = SCROLLBAR_AUTO_HIDE_DELAY) { function getListViewportClassName(stage: ScrollbarStage, className?: string) { return cn( - '-mr-2 min-h-0 flex-1 overflow-auto py-1.5 pt-0 pr-2 [scrollbar-gutter:stable]', + '-mr-2 min-h-0 flex-1 overflow-auto py-1.5 pt-0.5! pr-2 [scrollbar-gutter:stable]', '[&::-webkit-scrollbar-thumb:hover]:bg-[var(--color-scrollbar-thumb-hover)]', '[&::-webkit-scrollbar-thumb]:transition-[background] [&::-webkit-scrollbar-thumb]:duration-150 [&::-webkit-scrollbar-thumb]:ease-out', SCROLLBAR_THUMB_CLASS_BY_STAGE[stage], diff --git a/src/renderer/components/chat/resources/variants/AgentResourceList.tsx b/src/renderer/components/chat/resources/variants/AgentResourceList.tsx index c6a40d16c75..4935d85a2cb 100644 --- a/src/renderer/components/chat/resources/variants/AgentResourceList.tsx +++ b/src/renderer/components/chat/resources/variants/AgentResourceList.tsx @@ -35,6 +35,7 @@ type AgentResourceListProps = { onAddAgent?: () => void | Promise onOpenHistoryRecords?: () => void onSelectSession: (sessionId: string, session: AgentSessionEntity) => void + onSelectedAgentClick?: () => void | Promise onStartDraftAgent: (agentId: string) => void | Promise onStartMissingAgentDraft?: () => void | Promise resourceMenuItems?: readonly ConversationResourceMenuItem[] @@ -51,6 +52,7 @@ export function AgentResourceList({ onAddAgent, onOpenHistoryRecords, onSelectSession, + onSelectedAgentClick, onStartDraftAgent, onStartMissingAgentDraft, resourceMenuItems, @@ -263,6 +265,7 @@ export function AgentResourceList({ variant="agent" items={items} selectedId={selectedId} + selectedClickId={activeAgentId} status={listStatus} ariaLabel={t('agent.sidebar_title')} defaultGroupLabel={t('agent.sidebar_title')} @@ -279,6 +282,7 @@ export function AgentResourceList({ /> } onSelect={handleSelect} + onSelectedClick={() => void onSelectedAgentClick?.()} onReorder={handleReorder} getContextMenuActions={getContextMenuActions} onContextMenuAction={handleContextMenuAction} diff --git a/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx b/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx index c01d1f15a34..8d449a6e3fa 100644 --- a/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx +++ b/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx @@ -32,6 +32,7 @@ type AssistantResourceListProps = { onAddAssistant?: () => void | Promise onOpenHistoryRecords?: () => void onSelectTopic: (topic: Topic) => void | boolean + onSelectedAssistantClick?: () => void | Promise onStartDraftAssistant: (assistantId: string | null) => void | Promise resourceMenuItems?: readonly ConversationResourceMenuItem[] /** @@ -47,6 +48,7 @@ export function AssistantResourceList({ onAddAssistant, onOpenHistoryRecords, onSelectTopic, + onSelectedAssistantClick, onStartDraftAssistant, resourceMenuItems, onActiveAssistantDeleted @@ -283,6 +285,7 @@ export function AssistantResourceList({ variant="assistant" items={items} selectedId={selectedId} + selectedClickId={activeAssistantId} status={listStatus} ariaLabel={t('assistants.abbr')} defaultGroupLabel={t('assistants.abbr')} @@ -300,6 +303,7 @@ export function AssistantResourceList({ /> } onSelect={handleSelect} + onSelectedClick={() => void onSelectedAssistantClick?.()} onReorder={handleReorder} getContextMenuActions={getContextMenuActions} onContextMenuAction={handleContextMenuAction} diff --git a/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx b/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx index fca85599ddf..6c7a0fafd7f 100644 --- a/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx +++ b/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx @@ -85,6 +85,8 @@ export type ResourceEntityRailProps) => void | Promise onReorder?: (payload: ResourceListReorderPayload) => void | Promise onSelect: (item: T) => void | Promise + onSelectedClick?: (item: T) => void | Promise + selectedClickId?: string | null selectedId?: string | null status?: ResourceListStatus variant: 'agent' | 'assistant' @@ -126,6 +128,8 @@ export function ResourceEntityRail(null) const effectiveListRef = listRef ?? fallbackListRef const hasActiveResourceMenuItem = resourceMenuItems?.some((item) => item.active) ?? false + const effectiveSelectedId = hasActiveResourceMenuItem ? null : selectedId + const effectiveSelectedClickId = selectedClickId ?? selectedId + const handleItemClick = useCallback( + (item: T) => { + if (effectiveSelectedClickId === item.id && onSelectedClick) { + void onSelectedClick(item) + return + } + void onSelect(item) + }, + [effectiveSelectedClickId, onSelect, onSelectedClick] + ) const runContextMenuAction = useCallback( (item: T, action: ResolvedAction) => { if (!action.availability.enabled || !onContextMenuAction) return @@ -170,7 +186,7 @@ export function ResourceEntityRail runContextMenuAction(item, action)) : [] const row = ( - void onSelect(item)}> + handleItemClick(item)}> {item.icon} @@ -210,7 +226,7 @@ export function ResourceEntityRail ) }, - [getContextMenuActions, onContextMenuAction, onSelect, runContextMenuAction, t] + [getContextMenuActions, handleItemClick, onContextMenuAction, runContextMenuAction, t] ) const empty = useMemo(() => emptyFallback ??
, [emptyFallback]) const providerItems = useMemo( @@ -257,7 +273,7 @@ export function ResourceEntityRail { requestAnimationFrameSpy.mockRestore() }) + it('toggles the selected entity instead of selecting it again', () => { + const onSelect = vi.fn() + const onSelectedClick = vi.fn() + + render( + + ) + + fireEvent.click(screen.getByText('Assistant A').closest('[role="option"]') as HTMLElement) + expect(onSelectedClick).toHaveBeenCalledWith(ITEMS[0]) + expect(onSelect).not.toHaveBeenCalled() + + fireEvent.click(screen.getByText('Assistant B').closest('[role="option"]') as HTMLElement) + expect(onSelect).toHaveBeenCalledWith(ITEMS[1]) + expect(onSelectedClick).toHaveBeenCalledTimes(1) + }) + + it('uses the selected click id for repeat-click toggles when the visual selection is cleared', () => { + const onSelect = vi.fn() + const onSelectedClick = vi.fn() + + render( + + ) + + fireEvent.click(screen.getByText('Assistant A').closest('[role="option"]') as HTMLElement) + + expect(onSelectedClick).toHaveBeenCalledWith(ITEMS[0]) + expect(onSelect).not.toHaveBeenCalled() + }) + it('does not select the entity when a context-menu action is picked', () => { const onSelect = vi.fn() const onContextMenuAction = vi.fn() diff --git a/src/renderer/pages/agents/AgentPage.tsx b/src/renderer/pages/agents/AgentPage.tsx index 5c91b0d43d2..94b5fe48135 100644 --- a/src/renderer/pages/agents/AgentPage.tsx +++ b/src/renderer/pages/agents/AgentPage.tsx @@ -118,6 +118,18 @@ const AgentPage = () => { // survives AgentChat draft→persistent remounts (each branch mounts its own Shell) and app/page // re-entry, without bleeding into the assistant surface. const [sessionPaneOpen, setSessionPaneOpen] = useClassicLayoutRightPaneOpen('agent', isClassicSessionLayout) + const hasAutoOpenedClassicSessionPaneRef = useRef(false) + + useEffect(() => { + if (!isClassicSessionLayout) { + hasAutoOpenedClassicSessionPaneRef.current = false + return + } + + if (hasAutoOpenedClassicSessionPaneRef.current) return + hasAutoOpenedClassicSessionPaneRef.current = true + setSessionPaneOpen(true) + }, [isClassicSessionLayout, setSessionPaneOpen]) useEffect(() => { pendingSelectedSessionRef.current = null @@ -945,6 +957,7 @@ const AgentPage = () => { }} onOpenHistoryRecords={openHistoryRecords} onSelectSession={handleResourceSessionSelect} + onSelectedAgentClick={() => setSessionPaneOpen(!sessionPaneOpen)} onStartDraftAgent={(agentId) => startDraftSession({ agentId })} onStartMissingAgentDraft={startMissingAgentDraft} resourceMenuItems={resourceMenuItems} diff --git a/src/renderer/pages/agents/__tests__/AgentPage.test.tsx b/src/renderer/pages/agents/__tests__/AgentPage.test.tsx index f51528c0817..3da70dba6f7 100644 --- a/src/renderer/pages/agents/__tests__/AgentPage.test.tsx +++ b/src/renderer/pages/agents/__tests__/AgentPage.test.tsx @@ -514,11 +514,13 @@ vi.mock('@renderer/components/chat/resources/variants/AgentResourceList', () => activeAgentId, onAddAgent, onActiveAgentDeleted, + onSelectedAgentClick, resourceMenuItems }: { activeAgentId?: string | null onAddAgent?: () => void | Promise onActiveAgentDeleted?: (agentId: string) => void | Promise + onSelectedAgentClick?: () => void | Promise resourceMenuItems?: Array<{ id: string; label: ReactNode; onSelect: () => void | Promise }> }) => (
@@ -528,6 +530,9 @@ vi.mock('@renderer/components/chat/resources/variants/AgentResourceList', () => + {resourceMenuItems?.map((item) => ( + {resourceMenuItems ?.filter((item) => item.id === 'assistant-resource-view') .map((item) => ( @@ -682,6 +687,16 @@ describe('HomePage', () => { expect(screen.queryByTestId('home-tabs')).not.toBeInTheDocument() }) + it('toggles the classic topic pane when the selected assistant is clicked again', () => { + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Toggle selected assistant pane' })) + + expect(homeMocks.cacheSetPersist).toHaveBeenCalledWith('ui.chat.right_pane_open', false) + }) + it('renders the modern topic sidebar when topic display mode is time', () => { homeMocks.preferenceValues.set('topic.tab.display_mode', 'time') From f41a5bcd83455dd28d0fb39ea273f0a7172bc9d2 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sat, 4 Jul 2026 03:33:35 +0800 Subject: [PATCH 09/40] refactor: update styling and class names in ConversationPickerDialog and its tests --- .../components/resource/ConversationPickerDialog.tsx | 10 +++++----- .../__tests__/ConversationPickerDialog.test.tsx | 6 ++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/resource/ConversationPickerDialog.tsx b/src/renderer/components/resource/ConversationPickerDialog.tsx index 96682047b66..fba4c864f6a 100644 --- a/src/renderer/components/resource/ConversationPickerDialog.tsx +++ b/src/renderer/components/resource/ConversationPickerDialog.tsx @@ -127,17 +127,17 @@ export function ConversationPickerDialog({ /> {toolbar ?
{toolbar}
: null}
- + {/* Scrollbar is the scroll viewport; the cmdk list itself must not scroll so keyboard navigation's scroll-into-view bubbles up to the styled Scrollbar instead. */} {/* Pinned at the top, but hidden while searching so the query's first match keeps the default keyboard highlight instead of this row. */} {createAction && !query.trim() ? ( - + createAction.onSelect()}> {createAction.icon} @@ -156,12 +156,12 @@ export function ConversationPickerDialog({ {labels.loadingText}
) : visibleItems.length > 0 ? ( - + {visibleItems.map((item) => ( diff --git a/src/renderer/components/resource/__tests__/ConversationPickerDialog.test.tsx b/src/renderer/components/resource/__tests__/ConversationPickerDialog.test.tsx index b3321ce97cb..8a69dd09ab4 100644 --- a/src/renderer/components/resource/__tests__/ConversationPickerDialog.test.tsx +++ b/src/renderer/components/resource/__tests__/ConversationPickerDialog.test.tsx @@ -80,6 +80,10 @@ describe('ConversationPickerDialog', () => { // The list scrolls inside the shared Scrollbar viewport (auto-hiding thumb), not the cmdk list. expect(screen.getByText('Alpha Assistant').closest('[data-scrolling]')).toBeInTheDocument() + const firstRow = screen.getByText('Alpha Assistant').closest('[cmdk-item]') + expect(firstRow).toHaveClass('h-9', 'cursor-pointer', 'gap-2.5', 'px-2.5') + expect(firstRow?.closest('[cmdk-group]')).toHaveClass('[&_[cmdk-group-items]]:space-y-1') + const leadingSlot = screen.getByTestId('alpha-icon').parentElement expect(leadingSlot).toHaveClass('size-6', 'rounded-lg', 'text-foreground/70') expect(leadingSlot).not.toHaveClass('rounded-full', 'bg-secondary') @@ -149,6 +153,8 @@ describe('ConversationPickerDialog', () => { ) const createRow = screen.getByText('New Assistant') + expect(createRow.closest('[cmdk-item]')).toHaveClass('h-9', 'cursor-pointer', 'gap-2.5', 'px-2.5') + expect(createRow.closest('[cmdk-item]')?.closest('[cmdk-group]')).toHaveClass('[&_[cmdk-group-items]]:space-y-1') // Pinned above the first item. const firstItem = screen.getByText('Alpha Assistant') expect(createRow.compareDocumentPosition(firstItem) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() From 988cb1f39d6f4efb266fdac71acd7154190ce17d Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sat, 4 Jul 2026 03:47:08 +0800 Subject: [PATCH 10/40] refactor: update drag-and-drop capabilities in ResourceEntityRail and enhance ConversationPickerDialog styling --- .../resources/variants/ResourceEntityRail.tsx | 15 ++++++++------- .../__tests__/ResourceEntityRail.test.tsx | 13 +++++++++++-- .../resource/ConversationPickerDialog.tsx | 2 +- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx b/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx index 6c7a0fafd7f..521f9a4022c 100644 --- a/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx +++ b/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx @@ -70,8 +70,8 @@ export type ResourceEntityRailProps) { const { t } = useTranslation() - // Tag grouping splits the flat order across sections, so dragging an item between tags would have - // no meaningful `orderKey` target — disable reorder entirely while grouping by tag. - const reorderEnabled = !!onReorder && !groupByTag + const reorderEnabled = !!onReorder const fallbackListRef = useRef(null) const effectiveListRef = listRef ?? fallbackListRef const hasActiveResourceMenuItem = resourceMenuItems?.some((item) => item.active) ?? false @@ -285,8 +283,11 @@ export function ResourceEntityRail reorderEnabled && !item.pinned} - canDropItem={({ activeItem, targetGroupId }) => - reorderEnabled && !activeItem.pinned && targetGroupId !== ENTITY_RAIL_PINNED_GROUP_ID + canDropItem={({ activeItem, sourceGroupId, targetGroupId }) => + reorderEnabled && + !activeItem.pinned && + targetGroupId !== ENTITY_RAIL_PINNED_GROUP_ID && + sourceGroupId === targetGroupId } onReorder={reorderEnabled ? onReorder : undefined}> diff --git a/src/renderer/components/chat/resources/variants/__tests__/ResourceEntityRail.test.tsx b/src/renderer/components/chat/resources/variants/__tests__/ResourceEntityRail.test.tsx index fa810f167f9..983219ff04e 100644 --- a/src/renderer/components/chat/resources/variants/__tests__/ResourceEntityRail.test.tsx +++ b/src/renderer/components/chat/resources/variants/__tests__/ResourceEntityRail.test.tsx @@ -75,7 +75,8 @@ vi.mock('@renderer/components/VirtualList', () => { renderItem, role, scrollerProps, - scrollElementRef + scrollElementRef, + dragCapabilities }) => { const rows = buildGroupedVirtualRows(groups, Boolean(renderGroupHeader), Boolean(renderGroupFooter)) @@ -93,6 +94,7 @@ vi.mock('@renderer/components/VirtualList', () => { role={role} className={className} data-draggable={dragEnabled ? 'true' : 'false'} + data-drag-capabilities={JSON.stringify(dragCapabilities ?? null)} {...scrollerProps}> {rows.map((row, index) => { if (row.type === 'group-header') { @@ -380,7 +382,14 @@ describe('ResourceEntityRail', () => { // The flat default "Assistants" header never appears while grouping by tag. expect(screen.queryByText('Assistants')).not.toBeInTheDocument() expect(screen.getByTestId('work-a-icon')).toBeInTheDocument() - expect(screen.getByRole('listbox', { name: 'Assistants list' })).toHaveAttribute('data-draggable', 'false') + const listbox = screen.getByRole('listbox', { name: 'Assistants list' }) + expect(listbox).toHaveAttribute('data-draggable', 'true') + expect(JSON.parse(listbox.getAttribute('data-drag-capabilities') ?? '{}')).toMatchObject({ + groups: false, + items: true, + itemSameGroup: true, + itemCrossGroup: false + }) }) it('renders tag section headers with the shared hover and collapse affordance', () => { diff --git a/src/renderer/components/resource/ConversationPickerDialog.tsx b/src/renderer/components/resource/ConversationPickerDialog.tsx index fba4c864f6a..62be7e2a6c7 100644 --- a/src/renderer/components/resource/ConversationPickerDialog.tsx +++ b/src/renderer/components/resource/ConversationPickerDialog.tsx @@ -134,7 +134,7 @@ export function ConversationPickerDialog({ {/* Pinned at the top, but hidden while searching so the query's first match keeps the default keyboard highlight instead of this row. */} {createAction && !query.trim() ? ( - + Date: Sat, 4 Jul 2026 03:57:51 +0800 Subject: [PATCH 11/40] refactor: update default visible count for session and topic groups in left panel --- .../pages/agents/components/Sessions.tsx | 10 +++- .../components/__tests__/Sessions.test.tsx | 24 ++++++++++ .../pages/home/Tabs/components/Topics.tsx | 11 ++++- .../Tabs/components/__tests__/Topics.test.tsx | 48 +++++++++---------- 4 files changed, 64 insertions(+), 29 deletions(-) diff --git a/src/renderer/pages/agents/components/Sessions.tsx b/src/renderer/pages/agents/components/Sessions.tsx index 1bc5baaadae..27b9327cb64 100644 --- a/src/renderer/pages/agents/components/Sessions.tsx +++ b/src/renderer/pages/agents/components/Sessions.tsx @@ -96,6 +96,8 @@ type SessionsProps = ControlledSessionsProps const logger = loggerService.withContext('AgentSessions') const EMPTY_WORKSPACE_ROWS: AgentWorkspaceEntity[] = [] +const DEFAULT_SESSION_GROUP_VISIBLE_COUNT = 5 +const LEFT_PANEL_TIME_SESSION_GROUP_VISIBLE_COUNT = 50 type CreateSessionSeed = { agentId: string workspace?: AgentSessionWorkspaceSource @@ -307,6 +309,10 @@ const Sessions = ({ : sessionDisplayMode === 'workdir' || sessionDisplayMode === 'agent' ? sessionDisplayMode : 'time' + const defaultGroupVisibleCount = + !isRightPanel && displayMode === 'time' + ? LEFT_PANEL_TIME_SESSION_GROUP_VISIBLE_COUNT + : DEFAULT_SESSION_GROUP_VISIBLE_COUNT const isDraggableMode = displayMode !== 'time' const [rightPanelSessionExpansion, setRightPanelSessionExpansion] = useState([]) const sessionExpansion = isRightPanel @@ -1373,8 +1379,8 @@ const Sessions = ({ sectionBy={sessionSectionBy} collapsedState={collapsedSessionState} revealRequest={revealRequest} - defaultGroupVisibleCount={5} - groupLoadStep={5} + defaultGroupVisibleCount={defaultGroupVisibleCount} + groupLoadStep={DEFAULT_SESSION_GROUP_VISIBLE_COUNT} getSectionHeaderAction={getSectionHeaderAction} getGroupHeaderAction={getGroupHeaderAction} getGroupHeaderClassName={getGroupHeaderClassName} diff --git a/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx b/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx index c322b98f548..d592ccae406 100644 --- a/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx +++ b/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx @@ -811,6 +811,30 @@ describe('Sessions', () => { expect(pinMocks.usePins).not.toHaveBeenCalledWith('agent', { enabled: true }) }) + it('shows fifty sessions in left-panel time groups and expands the remaining items', () => { + preferenceMocks.values.set('agent.session.display_mode', 'time') + setupSessions({ + sessions: Array.from({ length: 56 }, (_, index) => + createSession({ + id: `session-${index + 1}`, + name: `Session ${index + 1}`, + orderKey: String(index + 1).padStart(3, '0'), + updatedAt: CURRENT_SESSION_ISO + }) + ) + }) + + render() + + expect(screen.getByText('Today')).toBeInTheDocument() + expect(screen.getByText('Session 50')).toBeInTheDocument() + expect(screen.queryByText('Session 51')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Expand display' })) + + expect(screen.getByText('Session 56')).toBeInTheDocument() + }) + it('starts a first-agent draft from the header when there are agents but no sessions', async () => { const onStartDraftSession = vi.fn() const onStartMissingAgentDraft = vi.fn() diff --git a/src/renderer/pages/home/Tabs/components/Topics.tsx b/src/renderer/pages/home/Tabs/components/Topics.tsx index 99eda6412a5..270b6831035 100644 --- a/src/renderer/pages/home/Tabs/components/Topics.tsx +++ b/src/renderer/pages/home/Tabs/components/Topics.tsx @@ -84,6 +84,8 @@ import { useTopicMenuActions } from './useTopicMenuActions' const logger = loggerService.withContext('Topics') const EMPTY_COLLAPSED_TOPIC_STATE: readonly string[] = [] +const DEFAULT_TOPIC_GROUP_VISIBLE_COUNT = 5 +const LEFT_PANEL_TIME_TOPIC_GROUP_VISIBLE_COUNT = 50 interface Props { activeTopic?: Topic @@ -226,6 +228,11 @@ export function Topics({ yuque: 'data.export.menus.yuque' }) const displayMode = isRightPanel ? 'time' : (topicDisplayMode ?? 'time') + const defaultGroupVisibleCount = isRightPanel + ? Number.POSITIVE_INFINITY + : displayMode === 'time' + ? LEFT_PANEL_TIME_TOPIC_GROUP_VISIBLE_COUNT + : DEFAULT_TOPIC_GROUP_VISIBLE_COUNT const isAssistantDisplayMode = displayMode === 'assistant' const topicExpansion = isAssistantDisplayMode ? topicExpansionAssistant : topicExpansionTime @@ -1036,8 +1043,8 @@ export function Topics({ sectionBy={topicSectionBy} collapsedState={collapsedTopicState} revealRequest={revealRequest} - defaultGroupVisibleCount={isRightPanel ? Number.POSITIVE_INFINITY : 5} - groupLoadStep={isRightPanel ? Number.POSITIVE_INFINITY : 5} + defaultGroupVisibleCount={defaultGroupVisibleCount} + groupLoadStep={isRightPanel ? Number.POSITIVE_INFINITY : DEFAULT_TOPIC_GROUP_VISIBLE_COUNT} getGroupHeaderAction={getGroupHeaderAction} getGroupHeaderContextMenu={getGroupHeaderContextMenu} getGroupHeaderIcon={getGroupHeaderIcon} diff --git a/src/renderer/pages/home/Tabs/components/__tests__/Topics.test.tsx b/src/renderer/pages/home/Tabs/components/__tests__/Topics.test.tsx index 050a98d34e3..5977dcecc0d 100644 --- a/src/renderer/pages/home/Tabs/components/__tests__/Topics.test.tsx +++ b/src/renderer/pages/home/Tabs/components/__tests__/Topics.test.tsx @@ -1349,7 +1349,7 @@ describe('Topics', () => { expect(topicStreamStatusMocks.markSeen).toHaveBeenCalledWith('topic-a') }) - it('shows five topics per group and loads five more within that group', () => { + it('shows fifty topics in left-panel time groups and expands the remaining items', () => { MockUsePreferenceUtils.setPreferenceValue('topic.tab.display_mode' as never, 'time') mockUseQuery.mockImplementation((path) => { if (path === '/pins') { @@ -1372,7 +1372,7 @@ describe('Topics', () => { } }) mockUseInfiniteQuery.mockReturnValue({ - pages: [{ items: createTopicPageItems(11) }], + pages: [{ items: createTopicPageItems(51) }], isLoading: false, isRefreshing: false, error: undefined, @@ -1386,19 +1386,18 @@ describe('Topics', () => { renderTopicList() expect(screen.getByText('Today')).toBeInTheDocument() - expect(screen.getByText('Topic 5')).toBeInTheDocument() - expect(screen.queryByText('Topic 6')).not.toBeInTheDocument() + expect(screen.getByText('Topic 50')).toBeInTheDocument() + expect(screen.queryByText('Topic 51')).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Show more conversations' })) - expect(screen.getByText('Topic 10')).toBeInTheDocument() - expect(screen.getByText('Topic 11')).toBeInTheDocument() + expect(screen.getByText('Topic 51')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Collapse conversations' })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Collapse conversations' })) - expect(screen.getByText('Topic 5')).toBeInTheDocument() - expect(screen.queryByText('Topic 6')).not.toBeInTheDocument() + expect(screen.getByText('Topic 50')).toBeInTheDocument() + expect(screen.queryByText('Topic 51')).not.toBeInTheDocument() }) it('keeps the expanded topic window after selecting a topic revealed by show more', () => { @@ -1424,7 +1423,7 @@ describe('Topics', () => { } }) mockUseInfiniteQuery.mockReturnValue({ - pages: [{ items: createTopicPageItems(11) }], + pages: [{ items: createTopicPageItems(51) }], isLoading: false, isRefreshing: false, error: undefined, @@ -1438,14 +1437,13 @@ describe('Topics', () => { const { rerenderTopicList, setActiveTopic } = renderTopicList() fireEvent.click(screen.getByRole('button', { name: 'Show more conversations' })) - fireEvent.click(getTopicRow('Topic 6')) + fireEvent.click(getTopicRow('Topic 51')) - expect(setActiveTopic).toHaveBeenCalledWith(expect.objectContaining({ id: 'topic-6' })) + expect(setActiveTopic).toHaveBeenCalledWith(expect.objectContaining({ id: 'topic-51' })) - rerenderTopicList(undefined, createRendererTopic({ id: 'topic-6', name: 'Topic 6' })) + rerenderTopicList(undefined, createRendererTopic({ id: 'topic-51', name: 'Topic 51' })) - expect(screen.getByText('Topic 10')).toBeInTheDocument() - expect(screen.getByText('Topic 11')).toBeInTheDocument() + expect(screen.getByText('Topic 51')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Collapse conversations' })).toBeInTheDocument() }) @@ -1505,7 +1503,7 @@ describe('Topics', () => { it('does not show the assistant section toggle action in time display mode', () => { MockUsePreferenceUtils.setPreferenceValue('topic.tab.display_mode' as never, 'time') mockUseInfiniteQuery.mockReturnValue({ - pages: [{ items: createTopicPageItems(6) }], + pages: [{ items: createTopicPageItems(51) }], isLoading: false, isRefreshing: false, error: undefined, @@ -1550,7 +1548,7 @@ describe('Topics', () => { } }) mockUseInfiniteQuery.mockReturnValue({ - pages: [{ items: createTopicPageItems(6) }], + pages: [{ items: createTopicPageItems(51) }], isLoading: false, isRefreshing: false, error: undefined, @@ -1566,10 +1564,10 @@ describe('Topics', () => { renderTopicList() const subscribedKeys = subscribeSpy.mock.calls.map(([key]) => key) - expect(subscribedKeys).toContain(topicStreamStatusCacheKey('topic-5')) - expect(subscribedKeys).toContain(topicStreamLastSeenCompletionCacheKey('topic-5')) - expect(subscribedKeys).not.toContain(topicStreamStatusCacheKey('topic-6')) - expect(subscribedKeys).not.toContain(topicStreamLastSeenCompletionCacheKey('topic-6')) + expect(subscribedKeys).toContain(topicStreamStatusCacheKey('topic-50')) + expect(subscribedKeys).toContain(topicStreamLastSeenCompletionCacheKey('topic-50')) + expect(subscribedKeys).not.toContain(topicStreamStatusCacheKey('topic-51')) + expect(subscribedKeys).not.toContain(topicStreamLastSeenCompletionCacheKey('topic-51')) } finally { subscribeSpy.mockRestore() } @@ -1717,7 +1715,7 @@ describe('Topics', () => { mockUseInfiniteQuery.mockReturnValue({ pages: [ { - items: createTopicPageItems(6) + items: createTopicPageItems(51) } ], isLoading: false, @@ -1733,12 +1731,12 @@ describe('Topics', () => { const { rerenderTopicList } = renderTopicList() expect(screen.getByRole('button', { name: 'Today' })).toHaveAttribute('aria-expanded', 'true') - expect(screen.queryByText('Topic 6')).not.toBeInTheDocument() + expect(screen.queryByText('Topic 51')).not.toBeInTheDocument() - rerenderTopicList({ itemId: 'topic-6', requestId: 1, clearFilters: true, clearQuery: true }) + rerenderTopicList({ itemId: 'topic-51', requestId: 1, clearFilters: true, clearQuery: true }) - expect(await screen.findByText('Topic 6')).toBeInTheDocument() - const revealedRow = screen.getByText('Topic 6').closest('[role="option"]') + expect(await screen.findByText('Topic 51')).toBeInTheDocument() + const revealedRow = screen.getByText('Topic 51').closest('[role="option"]') expect(revealedRow).not.toBeNull() expect(revealedRow!).toHaveAttribute('data-reveal-focus', 'true') expect(revealedRow!).toHaveClass('animation-resource-list-reveal-focus') From a595dfe42be5a1bae6b6862ad4d08141b96dadd0 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sat, 4 Jul 2026 03:58:28 +0800 Subject: [PATCH 12/40] refactor: simplify NavbarHeader class names in AgentChatNavbar and HeaderNavbar components --- .../pages/agents/components/AgentChatNavbar/index.tsx | 6 +----- src/renderer/pages/home/components/ChatNavbar/index.tsx | 4 +--- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/renderer/pages/agents/components/AgentChatNavbar/index.tsx b/src/renderer/pages/agents/components/AgentChatNavbar/index.tsx index 2e5884d5029..ea51ce5a451 100644 --- a/src/renderer/pages/agents/components/AgentChatNavbar/index.tsx +++ b/src/renderer/pages/agents/components/AgentChatNavbar/index.tsx @@ -29,11 +29,7 @@ const AgentChatNavbar = ({ }) return ( - +
= ({ showSidebarControls = true, sideb const newTopic = useResolvedCommand('topic.create') return ( - +
{showSidebarControls && From adb9e85ec07dcd11500b38991d14c7ad85ade2a0 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sat, 4 Jul 2026 13:16:36 +0800 Subject: [PATCH 13/40] refactor: enhance skill management integration in agent resources and sessions --- .../chat/resources/SessionListOptionsMenu.tsx | 24 ++++++++++++- .../resources/variants/AgentResourceList.tsx | 4 +++ .../resources/variants/ResourceEntityRail.tsx | 2 +- .../EntityResourceListActions.test.tsx | 30 ++++++++++++++++ .../__tests__/ResourceEntityRail.test.tsx | 31 +++++++++++++++++ .../shell/__tests__/RightPaneHost.test.tsx | 9 +++-- .../components/chat/shell/paneLayout.ts | 4 +-- src/renderer/i18n/locales/en-us.json | 5 +++ src/renderer/i18n/locales/zh-cn.json | 5 +++ src/renderer/i18n/locales/zh-tw.json | 5 +++ src/renderer/pages/agents/AgentPage.tsx | 5 ++- .../__tests__/AgentChatArtifactPane.test.tsx | 14 ++++---- .../agents/__tests__/AgentChatLocate.test.tsx | 4 +-- .../__tests__/AgentChatSettingsPanel.test.tsx | 8 ++--- .../pages/agents/components/Sessions.tsx | 6 +++- .../components/__tests__/Sessions.test.tsx | 34 +++++++++++++++++++ .../pages/home/Tabs/components/Topics.tsx | 2 +- .../__tests__/TopicRightPane.test.tsx | 4 +-- 18 files changed, 172 insertions(+), 24 deletions(-) diff --git a/src/renderer/components/chat/resources/SessionListOptionsMenu.tsx b/src/renderer/components/chat/resources/SessionListOptionsMenu.tsx index 16500f7d70f..4155ded7607 100644 --- a/src/renderer/components/chat/resources/SessionListOptionsMenu.tsx +++ b/src/renderer/components/chat/resources/SessionListOptionsMenu.tsx @@ -20,23 +20,33 @@ const SESSION_DISPLAY_ICONS: Record = { type SessionListOptionsMenuProps = { manageAgentsActive?: boolean + manageSkillsActive?: boolean + manageSkillsIcon?: ReactNode mode: AgentSessionDisplayMode onChange: (mode: AgentSessionDisplayMode) => void onManageAgents?: () => void | Promise + onManageSkills?: () => void | Promise onOpenHistoryRecords?: () => void sectionId?: string } export function SessionListOptionsMenu({ manageAgentsActive, + manageSkillsActive, + manageSkillsIcon, mode, onChange, onManageAgents, + onManageSkills, onOpenHistoryRecords, sectionId }: SessionListOptionsMenuProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) + const hasManagementItems = !!(onManageAgents || onManageSkills) + const manageSkillsMenuIcon = manageSkillsIcon ? ( + {manageSkillsIcon} + ) : undefined return ( @@ -91,7 +101,7 @@ export function SessionListOptionsMenu({ }} /> )} - {onManageAgents && } + {hasManagementItems && } {onManageAgents && ( )} + {onManageSkills && ( + { + void onManageSkills() + setOpen(false) + }} + /> + )} diff --git a/src/renderer/components/chat/resources/variants/AgentResourceList.tsx b/src/renderer/components/chat/resources/variants/AgentResourceList.tsx index 4935d85a2cb..37d6c42fce9 100644 --- a/src/renderer/components/chat/resources/variants/AgentResourceList.tsx +++ b/src/renderer/components/chat/resources/variants/AgentResourceList.tsx @@ -85,6 +85,7 @@ export function AgentResourceList({ const [deletingAgentId, setDeletingAgentId] = useState(null) const [editDialogTarget, setEditDialogTarget] = useState(null) const manageAgentsMenuItem = resourceMenuItems?.find((item) => item.id === 'agent-resource-view') + const manageSkillsMenuItem = resourceMenuItems?.find((item) => item.id === 'skill-resource-view') const agentPinnedIdSet = useMemo(() => new Set(agentPinnedIds), [agentPinnedIds]) const isAgentPinActionDisabled = isAgentPinsLoading || isAgentPinsRefreshing || isAgentPinsMutating const sessionItems = useMemo( @@ -275,9 +276,12 @@ export function AgentResourceList({ headerActions={ void setSessionDisplayMode(nextMode)} onManageAgents={manageAgentsMenuItem?.onSelect} + onManageSkills={manageSkillsMenuItem?.onSelect} onOpenHistoryRecords={onOpenHistoryRecords} /> } diff --git a/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx b/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx index 521f9a4022c..3f8648ee227 100644 --- a/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx +++ b/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx @@ -141,7 +141,7 @@ export function ResourceEntityRail item.active) ?? false const effectiveSelectedId = hasActiveResourceMenuItem ? null : selectedId - const effectiveSelectedClickId = selectedClickId ?? selectedId + const effectiveSelectedClickId = hasActiveResourceMenuItem ? null : (selectedClickId ?? selectedId) const handleItemClick = useCallback( (item: T) => { if (effectiveSelectedClickId === item.id && onSelectedClick) { diff --git a/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx b/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx index 6a6c980ff81..60d075c6126 100644 --- a/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx +++ b/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx @@ -403,6 +403,36 @@ describe('classic layout entity resource list actions', () => { expect(preferenceMocks.setPreference).toHaveBeenCalledWith('agent.session.display_mode', 'workdir') }) + it('passes skill management entries into the classic agent rail display menu', () => { + const onManageSkills = vi.fn() + + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'agent.skill.manage.title' })) + + expect(onManageSkills).toHaveBeenCalledTimes(1) + expect(screen.getByRole('button', { name: 'agent.manage.title' })).toBeInTheDocument() + }) + it('keeps classic agent rail history in the shared display menu without section toggles', () => { const onOpenHistoryRecords = vi.fn() diff --git a/src/renderer/components/chat/resources/variants/__tests__/ResourceEntityRail.test.tsx b/src/renderer/components/chat/resources/variants/__tests__/ResourceEntityRail.test.tsx index 983219ff04e..38cc9254d6a 100644 --- a/src/renderer/components/chat/resources/variants/__tests__/ResourceEntityRail.test.tsx +++ b/src/renderer/components/chat/resources/variants/__tests__/ResourceEntityRail.test.tsx @@ -288,6 +288,37 @@ describe('ResourceEntityRail', () => { expect(onSelect).not.toHaveBeenCalled() }) + it('selects the entity instead of toggling it while a resource menu item is active', () => { + const onSelect = vi.fn() + const onSelectedClick = vi.fn() + + render( + + ) + + fireEvent.click(screen.getByText('Assistant A').closest('[role="option"]') as HTMLElement) + + expect(onSelect).toHaveBeenCalledWith(ITEMS[0]) + expect(onSelectedClick).not.toHaveBeenCalled() + }) + it('does not select the entity when a context-menu action is picked', () => { const onSelect = vi.fn() const onContextMenuAction = vi.fn() diff --git a/src/renderer/components/chat/shell/__tests__/RightPaneHost.test.tsx b/src/renderer/components/chat/shell/__tests__/RightPaneHost.test.tsx index c4a52b6cb79..0d77722e49f 100644 --- a/src/renderer/components/chat/shell/__tests__/RightPaneHost.test.tsx +++ b/src/renderer/components/chat/shell/__tests__/RightPaneHost.test.tsx @@ -20,7 +20,7 @@ interface ResizeObserverMockInstance { const resizeObserverMockInstances: ResizeObserverMockInstance[] = [] const persistCacheMock = vi.hoisted(() => { - const state = { width: 300 } + const state = { width: 280 } return { state, @@ -118,6 +118,11 @@ describe('RightPaneHost', () => { expect(container.querySelector('[data-right-pane-resize-handle]')).not.toBeInTheDocument() }) + it('uses the configured right pane default and minimum widths', () => { + expect(ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH).toBe(280) + expect(ARTIFACT_RIGHT_PANE_MIN_WIDTH).toBe(280) + }) + it('caps its width when reserving space for the conversation center', () => { render( @@ -209,7 +214,7 @@ describe('RightPaneHost', () => { expect(onOpenAnimationComplete).not.toHaveBeenCalled() fireEvent.mouseMove(document, { clientX: 300 }) - fireEvent.mouseMove(document, { clientX: 500 }) + fireEvent.mouseMove(document, { clientX: 600 }) fireEvent.mouseMove(document, { clientX: 20 }) expect(persistCacheMock.setWidth).toHaveBeenNthCalledWith(1, 500) diff --git a/src/renderer/components/chat/shell/paneLayout.ts b/src/renderer/components/chat/shell/paneLayout.ts index f39949b74e7..74a38860563 100644 --- a/src/renderer/components/chat/shell/paneLayout.ts +++ b/src/renderer/components/chat/shell/paneLayout.ts @@ -14,7 +14,7 @@ export const RESOURCE_LIST_PANE_COLLAPSE_DRAG_THRESHOLD = 200 export const RESOURCE_LIST_PANE_AUTO_COLLAPSE_WIDTH = 540 export const RESOURCE_LIST_PANE_CACHE_KEY = 'ui.chat.sidebar.width' -export const ARTIFACT_RIGHT_PANE_MIN_WIDTH = 300 -export const ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH = 300 +export const ARTIFACT_RIGHT_PANE_MIN_WIDTH = 280 +export const ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH = 280 export const ARTIFACT_RIGHT_PANE_MAX_WIDTH = 720 export const ARTIFACT_RIGHT_PANE_CACHE_KEY = 'ui.chat.artifact_pane.width' diff --git a/src/renderer/i18n/locales/en-us.json b/src/renderer/i18n/locales/en-us.json index ed008c4a4a5..fc1d62f4c42 100644 --- a/src/renderer/i18n/locales/en-us.json +++ b/src/renderer/i18n/locales/en-us.json @@ -787,6 +787,11 @@ } }, "sidebar_title": "Agents", + "skill": { + "manage": { + "title": "Manage skills" + } + }, "todo": { "mock": { "actions": { diff --git a/src/renderer/i18n/locales/zh-cn.json b/src/renderer/i18n/locales/zh-cn.json index d7cda0943dd..c0f300a0f30 100644 --- a/src/renderer/i18n/locales/zh-cn.json +++ b/src/renderer/i18n/locales/zh-cn.json @@ -787,6 +787,11 @@ } }, "sidebar_title": "智能体", + "skill": { + "manage": { + "title": "管理技能" + } + }, "todo": { "mock": { "actions": { diff --git a/src/renderer/i18n/locales/zh-tw.json b/src/renderer/i18n/locales/zh-tw.json index 85843a2c779..3f096656a52 100644 --- a/src/renderer/i18n/locales/zh-tw.json +++ b/src/renderer/i18n/locales/zh-tw.json @@ -787,6 +787,11 @@ } }, "sidebar_title": "Agents", + "skill": { + "manage": { + "title": "管理技能" + } + }, "todo": { "mock": { "actions": { diff --git a/src/renderer/pages/agents/AgentPage.tsx b/src/renderer/pages/agents/AgentPage.tsx index 94b5fe48135..589a90df5fc 100644 --- a/src/renderer/pages/agents/AgentPage.tsx +++ b/src/renderer/pages/agents/AgentPage.tsx @@ -957,7 +957,10 @@ const AgentPage = () => { }} onOpenHistoryRecords={openHistoryRecords} onSelectSession={handleResourceSessionSelect} - onSelectedAgentClick={() => setSessionPaneOpen(!sessionPaneOpen)} + onSelectedAgentClick={() => { + closeResourceView() + setSessionPaneOpen(!sessionPaneOpen) + }} onStartDraftAgent={(agentId) => startDraftSession({ agentId })} onStartMissingAgentDraft={startMissingAgentDraft} resourceMenuItems={resourceMenuItems} diff --git a/src/renderer/pages/agents/__tests__/AgentChatArtifactPane.test.tsx b/src/renderer/pages/agents/__tests__/AgentChatArtifactPane.test.tsx index 444307ca306..4ff12dec876 100644 --- a/src/renderer/pages/agents/__tests__/AgentChatArtifactPane.test.tsx +++ b/src/renderer/pages/agents/__tests__/AgentChatArtifactPane.test.tsx @@ -31,9 +31,9 @@ vi.mock('@cherrystudio/ui', async (importOriginal) => ({ vi.mock('@renderer/components/chat', () => ({ ARTIFACT_RIGHT_PANE_CACHE_KEY: 'ui.chat.artifact_pane.width', - ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 300, + ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 280, ARTIFACT_RIGHT_PANE_MAX_WIDTH: 720, - ARTIFACT_RIGHT_PANE_MIN_WIDTH: 300, + ARTIFACT_RIGHT_PANE_MIN_WIDTH: 280, ConversationCenterState: ({ state }: { state: string }) => (
), @@ -119,9 +119,9 @@ vi.mock('@renderer/components/chat', () => ({ vi.mock('@renderer/components/chat/shell/RightPaneHost', () => ({ ARTIFACT_RIGHT_PANE_CACHE_KEY: 'ui.chat.artifact_pane.width', - ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 300, + ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 280, ARTIFACT_RIGHT_PANE_MAX_WIDTH: 720, - ARTIFACT_RIGHT_PANE_MIN_WIDTH: 300, + ARTIFACT_RIGHT_PANE_MIN_WIDTH: 280, RightPaneHost: ({ children, open, @@ -672,10 +672,10 @@ describe('AgentChat artifact pane', () => { fireEvent.click(shortcut) expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-open', 'true') - expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-width', '300') + expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-width', '280') expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-resizable', 'true') - expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-min-width', '300') - expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-default-width', '300') + expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-min-width', '280') + expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-default-width', '280') expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-max-width', '720') expect(screen.getByTestId('artifact-right-pane')).toHaveAttribute('data-cache-key', 'ui.chat.artifact_pane.width') expect(screen.getByTestId('artifact-right-pane').getAttribute('data-class-name')).not.toContain('p-2') diff --git a/src/renderer/pages/agents/__tests__/AgentChatLocate.test.tsx b/src/renderer/pages/agents/__tests__/AgentChatLocate.test.tsx index f4da5d68fac..fdb9b21d98f 100644 --- a/src/renderer/pages/agents/__tests__/AgentChatLocate.test.tsx +++ b/src/renderer/pages/agents/__tests__/AgentChatLocate.test.tsx @@ -30,9 +30,9 @@ vi.mock('@cherrystudio/ui', async (importOriginal) => ({ vi.mock('@renderer/components/chat', () => ({ ARTIFACT_RIGHT_PANE_CACHE_KEY: 'ui.chat.artifact_pane.width', - ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 300, + ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 280, ARTIFACT_RIGHT_PANE_MAX_WIDTH: 720, - ARTIFACT_RIGHT_PANE_MIN_WIDTH: 300, + ARTIFACT_RIGHT_PANE_MIN_WIDTH: 280, ChatAppShell: ({ pane, paneOpen, diff --git a/src/renderer/pages/agents/__tests__/AgentChatSettingsPanel.test.tsx b/src/renderer/pages/agents/__tests__/AgentChatSettingsPanel.test.tsx index 6a1e3e709e5..aa710aa39f5 100644 --- a/src/renderer/pages/agents/__tests__/AgentChatSettingsPanel.test.tsx +++ b/src/renderer/pages/agents/__tests__/AgentChatSettingsPanel.test.tsx @@ -38,9 +38,9 @@ vi.mock('@renderer/ipc', () => ({ vi.mock('@renderer/components/chat', () => ({ ARTIFACT_RIGHT_PANE_CACHE_KEY: 'ui.chat.artifact_pane.width', - ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 300, + ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 280, ARTIFACT_RIGHT_PANE_MAX_WIDTH: 540, - ARTIFACT_RIGHT_PANE_MIN_WIDTH: 300, + ARTIFACT_RIGHT_PANE_MIN_WIDTH: 280, ConversationCenterState: ({ state }: { state: string }) => (
), @@ -78,9 +78,9 @@ vi.mock('@renderer/components/chat', () => ({ vi.mock('@renderer/components/chat/shell/RightPaneHost', () => ({ ARTIFACT_RIGHT_PANE_CACHE_KEY: 'ui.chat.artifact_pane.width', - ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 300, + ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 280, ARTIFACT_RIGHT_PANE_MAX_WIDTH: 540, - ARTIFACT_RIGHT_PANE_MIN_WIDTH: 300, + ARTIFACT_RIGHT_PANE_MIN_WIDTH: 280, RightPaneHost: ({ children, open }: PropsWithChildren<{ open?: boolean }>) => (
{open ? children : null} diff --git a/src/renderer/pages/agents/components/Sessions.tsx b/src/renderer/pages/agents/components/Sessions.tsx index 27b9327cb64..386504e3b22 100644 --- a/src/renderer/pages/agents/components/Sessions.tsx +++ b/src/renderer/pages/agents/components/Sessions.tsx @@ -1367,6 +1367,7 @@ const Sessions = ({ : 'idle' const hasActiveResourceMenuItem = resourceMenuItems?.some((item) => item.active) ?? false const manageAgentsMenuItem = resourceMenuItems?.find((item) => item.id === 'agent-resource-view') + const manageSkillsMenuItem = resourceMenuItems?.find((item) => item.id === 'skill-resource-view') return ( @@ -1404,7 +1405,7 @@ const Sessions = ({ onGroupHeaderSelectItem={handleSelectSession} onReorder={handleSessionReorder} onCollapsedStateChange={handleSessionCollapsedStateChange}> - + {isRightPanel ? ( void setSessionDisplayMode(nextMode)} onManageAgents={manageAgentsMenuItem?.onSelect} + onManageSkills={manageSkillsMenuItem?.onSelect} onOpenHistoryRecords={onOpenHistoryRecords} sectionId={ displayMode === 'agent' diff --git a/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx b/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx index d592ccae406..527f20937dc 100644 --- a/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx +++ b/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx @@ -451,6 +451,7 @@ vi.mock('react-i18next', () => ({ 'agent.session.group.yesterday': 'Yesterday', 'agent.session.list.title': 'Tasks', 'agent.session.new': 'New task', + 'agent.skill.manage.title': 'Manage skills', 'agent.pin.title': 'Pin Agent', 'agent.session.pin.title': 'Pin task', 'agent.session.reorder.error.failed': 'Failed to reorder tasks', @@ -1167,6 +1168,39 @@ describe('Sessions', () => { expect(screen.getByText('Alpha session').closest('[role="option"]')).not.toHaveAttribute('data-selected') }) + it('shows the skill resource menu entry with agent management in the display menu', () => { + const onManageAgents = vi.fn() + const onManageSkills = vi.fn() + setupSessions({ + sessions: [createSession({ id: 'session-a', name: 'Alpha session', orderKey: 'a' })] + }) + + render( + + ) + + expect(screen.queryByRole('button', { name: 'Manage skills' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Display mode' })) + expect(screen.getByRole('button', { name: 'Manage Agents' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Manage skills' })) + + expect(onManageSkills).toHaveBeenCalledTimes(1) + expect(onManageAgents).not.toHaveBeenCalled() + }) + it('creates sessions from agent group actions', async () => { const onStartDraftSession = vi.fn() preferenceMocks.values.set('agent.session.display_mode', 'agent') diff --git a/src/renderer/pages/home/Tabs/components/Topics.tsx b/src/renderer/pages/home/Tabs/components/Topics.tsx index 270b6831035..4e850e0433f 100644 --- a/src/renderer/pages/home/Tabs/components/Topics.tsx +++ b/src/renderer/pages/home/Tabs/components/Topics.tsx @@ -1065,7 +1065,7 @@ export function Topics({ onGroupHeaderSelectItem={handleGroupHeaderSelectTopic} onReorder={handleTopicReorder} onCollapsedStateChange={handleTopicCollapsedStateChange}> - + {isRightPanel ? ( { return { ARTIFACT_RIGHT_PANE_CACHE_KEY: 'ui.chat.artifact_pane.width', - ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 300, + ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH: 280, ARTIFACT_RIGHT_PANE_MAX_WIDTH: 720, - ARTIFACT_RIGHT_PANE_MIN_WIDTH: 300, + ARTIFACT_RIGHT_PANE_MIN_WIDTH: 280, RightPaneHost: ({ children, onCloseAnimationComplete, From f87a9e3a287f20fe14077685158398b62d25fd4b Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sat, 4 Jul 2026 13:43:45 +0800 Subject: [PATCH 14/40] refactor: implement icon type selection for assistants and agents, enhancing avatar display options --- .../resources/variants/AgentResourceList.tsx | 88 ++++++++++--- .../variants/AssistantResourceList.tsx | 98 ++++++++++++--- .../resources/variants/ResourceEntityRail.tsx | 10 +- .../EntityResourceListActions.test.tsx | 118 ++++++++++++------ .../__tests__/ResourceEntityRail.test.tsx | 20 +++ 5 files changed, 257 insertions(+), 77 deletions(-) diff --git a/src/renderer/components/chat/resources/variants/AgentResourceList.tsx b/src/renderer/components/chat/resources/variants/AgentResourceList.tsx index 37d6c42fce9..67178cee0a5 100644 --- a/src/renderer/components/chat/resources/variants/AgentResourceList.tsx +++ b/src/renderer/components/chat/resources/variants/AgentResourceList.tsx @@ -1,5 +1,6 @@ import { usePreference } from '@data/hooks/usePreference' import { loggerService } from '@logger' +import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import type { ResolvedAction } from '@renderer/components/chat/actions/actionTypes' import EmojiIcon from '@renderer/components/EmojiIcon' import { ResourceEditDialogHost, type ResourceEditDialogTarget } from '@renderer/components/resource/dialogs' @@ -10,7 +11,9 @@ import { usePins } from '@renderer/hooks/usePins' import { getAgentAvatarFromConfiguration } from '@renderer/utils/agent' import { formatErrorMessageWithPrefix } from '@renderer/utils/error' import type { AgentSessionEntity } from '@shared/data/api/schemas/agentSessions' -import { Pin, PinOff, Plus, SquarePen, Trash2 } from 'lucide-react' +import type { AssistantIconType } from '@shared/data/preference/preferenceTypes' +import { isUniqueModelId, parseUniqueModelId } from '@shared/data/types/model' +import { Check, Pin, PinOff, Plus, Smile, SquarePen, Trash2 } from 'lucide-react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -24,7 +27,25 @@ const logger = loggerService.withContext('AgentResourceList') const AGENT_ENTITY_EDIT_ACTION_ID = 'agent-entity.edit' const AGENT_ENTITY_TOGGLE_PIN_ACTION_ID = 'agent-entity.toggle-pin' +const AGENT_ENTITY_ICON_TYPE_ACTION_ID = 'agent-entity.icon-type' const AGENT_ENTITY_DELETE_ACTION_ID = 'agent-entity.delete' +const ASSISTANT_ICON_TYPE_OPTIONS: AssistantIconType[] = ['emoji', 'model', 'none'] +const ASSISTANT_ICON_TYPE_LABEL_KEYS: Record = { + emoji: 'settings.assistant.icon.type.emoji', + model: 'settings.assistant.icon.type.model', + none: 'settings.assistant.icon.type.none' +} + +function buildModelAvatarModel(uniqueModelId: unknown, modelName: string | null | undefined) { + if (!isUniqueModelId(uniqueModelId)) return undefined + + const { providerId, modelId } = parseUniqueModelId(uniqueModelId) + return { + id: modelId, + name: modelName || modelId, + providerId + } +} type SessionListItem = AgentSessionEntity & { pinned?: boolean @@ -59,6 +80,8 @@ export function AgentResourceList({ onActiveAgentDeleted }: AgentResourceListProps) { const { t } = useTranslation() + const [assistantIconType, setAssistantIconType] = usePreference('assistant.icon_type') + const [defaultModelId] = usePreference('chat.default_model_id') const [sessionDisplayMode, setSessionDisplayMode] = usePreference('agent.session.display_mode') const { agents, isLoading: isAgentsLoading, error: agentsError, refetch: refetchAgents } = useAgents() const { @@ -95,21 +118,29 @@ export function AgentResourceList({ const entities = useMemo( () => - agents.map((agent) => ({ - id: agent.id, - name: agent.name, - orderKey: agent.orderKey, - pinned: agentPinnedIdSet.has(agent.id), - icon: ( - - ) - })), - [agents, agentPinnedIdSet] + agents.map((agent) => { + const modelAvatarModel = buildModelAvatarModel(agent.model ?? defaultModelId, agent.modelName ?? undefined) + const icon = + assistantIconType === 'none' ? undefined : assistantIconType === 'model' && modelAvatarModel ? ( + + ) : ( + + ) + + return { + id: agent.id, + name: agent.name, + orderKey: agent.orderKey, + pinned: agentPinnedIdSet.has(agent.id), + icon + } + }), + [agentPinnedIdSet, agents, assistantIconType, defaultModelId] ) const sortSessionsForEntity = useCallback( @@ -228,6 +259,23 @@ export function AgentResourceList({ availability: { visible: true, enabled: !isAgentPinActionDisabled }, children: [] }, + { + id: AGENT_ENTITY_ICON_TYPE_ACTION_ID, + label: t('assistants.icon.type'), + icon: , + order: 25, + danger: false, + availability: { visible: true, enabled: true }, + children: ASSISTANT_ICON_TYPE_OPTIONS.map((type) => ({ + id: `${AGENT_ENTITY_ICON_TYPE_ACTION_ID}.${type}`, + label: t(ASSISTANT_ICON_TYPE_LABEL_KEYS[type]), + icon: assistantIconType === type ? : , + order: 0, + danger: false, + availability: { visible: true, enabled: true }, + children: [] + })) + }, { id: AGENT_ENTITY_DELETE_ACTION_ID, label: t('agent.delete.title'), @@ -240,7 +288,7 @@ export function AgentResourceList({ } ] }, - [agentPinnedIdSet, deletingAgentId, isAgentPinActionDisabled, t] + [agentPinnedIdSet, assistantIconType, deletingAgentId, isAgentPinActionDisabled, t] ) const handleContextMenuAction = useCallback( @@ -253,11 +301,15 @@ export function AgentResourceList({ void handleToggleAgentPin(item.id) return } + if (action.id.startsWith(`${AGENT_ENTITY_ICON_TYPE_ACTION_ID}.`)) { + void setAssistantIconType(action.id.slice(AGENT_ENTITY_ICON_TYPE_ACTION_ID.length + 1) as AssistantIconType) + return + } if (action.id === AGENT_ENTITY_DELETE_ACTION_ID) { void handleDeleteAgent(item.id) } }, - [handleDeleteAgent, handleToggleAgentPin, openAgentEditor] + [handleDeleteAgent, handleToggleAgentPin, openAgentEditor, setAssistantIconType] ) return ( diff --git a/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx b/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx index 8d449a6e3fa..bcca29c0322 100644 --- a/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx +++ b/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx @@ -1,5 +1,6 @@ import { usePreference } from '@data/hooks/usePreference' import { loggerService } from '@logger' +import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import type { ResolvedAction } from '@renderer/components/chat/actions/actionTypes' import EmojiIcon from '@renderer/components/EmojiIcon' import { ResourceEditDialogHost, type ResourceEditDialogTarget } from '@renderer/components/resource/dialogs' @@ -10,7 +11,9 @@ import { usePins } from '@renderer/hooks/usePins' import { mapApiTopicToRendererTopic, useTopicMutations } from '@renderer/hooks/useTopic' import type { Topic } from '@renderer/types/topic' import { formatErrorMessageWithPrefix } from '@renderer/utils/error' -import { Bot, Edit3, PinIcon, PinOffIcon, Plus, Tags, Trash2 } from 'lucide-react' +import type { AssistantIconType } from '@shared/data/preference/preferenceTypes' +import { isUniqueModelId, parseUniqueModelId } from '@shared/data/types/model' +import { Bot, Check, Edit3, PinIcon, PinOffIcon, Plus, Smile, Tags, Trash2 } from 'lucide-react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -25,7 +28,25 @@ const logger = loggerService.withContext('AssistantResourceList') const ASSISTANT_ENTITY_EDIT_ACTION_ID = 'assistant-entity.edit' const ASSISTANT_ENTITY_TOGGLE_PIN_ACTION_ID = 'assistant-entity.toggle-pin' const ASSISTANT_ENTITY_TOGGLE_TAG_GROUPING_ACTION_ID = 'assistant-entity.toggle-tag-grouping' +const ASSISTANT_ENTITY_ICON_TYPE_ACTION_ID = 'assistant-entity.icon-type' const ASSISTANT_ENTITY_DELETE_ACTION_ID = 'assistant-entity.delete' +const ASSISTANT_ICON_TYPE_OPTIONS: AssistantIconType[] = ['emoji', 'model', 'none'] +const ASSISTANT_ICON_TYPE_LABEL_KEYS: Record = { + emoji: 'settings.assistant.icon.type.emoji', + model: 'settings.assistant.icon.type.model', + none: 'settings.assistant.icon.type.none' +} + +function buildModelAvatarModel(uniqueModelId: unknown, modelName: string | null | undefined) { + if (!isUniqueModelId(uniqueModelId)) return undefined + + const { providerId, modelId } = parseUniqueModelId(uniqueModelId) + return { + id: modelId, + name: modelName || modelId, + providerId + } +} type AssistantResourceListProps = { activeAssistantId?: string | null @@ -55,6 +76,8 @@ export function AssistantResourceList({ }: AssistantResourceListProps) { const { t } = useTranslation() const [assistantSortType, setAssistantSortType] = usePreference('assistant.tab.sort_type') + const [assistantIconType, setAssistantIconType] = usePreference('assistant.icon_type') + const [defaultModelId] = usePreference('chat.default_model_id') const [topicDisplayMode, setTopicDisplayMode] = usePreference('topic.tab.display_mode') const isTagGrouping = assistantSortType === 'tags' const manageAssistantsMenuItem = resourceMenuItems?.find((item) => item.id === 'assistant-resource-view') @@ -96,21 +119,32 @@ export function AssistantResourceList({ const entities = useMemo( () => - assistants.map((assistant) => ({ - id: assistant.id, - name: assistant.name, - orderKey: assistant.orderKey, - pinned: assistantPinnedIdSet.has(assistant.id), - tag: assistant.tags?.[0]?.name, - icon: assistant.emoji ? ( - - ) : ( - - - + assistants.map((assistant) => { + const modelAvatarModel = buildModelAvatarModel( + assistant.modelId ?? defaultModelId, + assistant.modelName ?? undefined ) - })), - [assistants, assistantPinnedIdSet] + const icon = + assistantIconType === 'none' ? undefined : assistantIconType === 'model' && modelAvatarModel ? ( + + ) : assistant.emoji ? ( + + ) : ( + + + + ) + + return { + id: assistant.id, + name: assistant.name, + orderKey: assistant.orderKey, + pinned: assistantPinnedIdSet.has(assistant.id), + tag: assistant.tags?.[0]?.name, + icon + } + }), + [assistantIconType, assistants, assistantPinnedIdSet, defaultModelId] ) const sortTopicsForEntity = useCallback( @@ -234,11 +268,28 @@ export function AssistantResourceList({ availability: { visible: true, enabled: !isAssistantPinActionDisabled }, children: [] }, + { + id: ASSISTANT_ENTITY_ICON_TYPE_ACTION_ID, + label: t('assistants.icon.type'), + icon: , + order: 25, + danger: false, + availability: { visible: true, enabled: true }, + children: ASSISTANT_ICON_TYPE_OPTIONS.map((type) => ({ + id: `${ASSISTANT_ENTITY_ICON_TYPE_ACTION_ID}.${type}`, + label: t(ASSISTANT_ICON_TYPE_LABEL_KEYS[type]), + icon: assistantIconType === type ? : , + order: 0, + danger: false, + availability: { visible: true, enabled: true }, + children: [] + })) + }, { id: ASSISTANT_ENTITY_TOGGLE_TAG_GROUPING_ACTION_ID, label: isTagGrouping ? t('assistants.tags.ungroup') : t('assistants.tags.group_by'), icon: , - order: 25, + order: 30, danger: false, availability: { visible: true, enabled: true }, children: [] @@ -255,7 +306,7 @@ export function AssistantResourceList({ } ] }, - [assistantPinnedIdSet, deletingAssistantId, isAssistantPinActionDisabled, isTagGrouping, t] + [assistantIconType, assistantPinnedIdSet, deletingAssistantId, isAssistantPinActionDisabled, isTagGrouping, t] ) const handleContextMenuAction = useCallback( @@ -272,11 +323,22 @@ export function AssistantResourceList({ void setAssistantSortType(isTagGrouping ? 'list' : 'tags') return } + if (action.id.startsWith(`${ASSISTANT_ENTITY_ICON_TYPE_ACTION_ID}.`)) { + void setAssistantIconType(action.id.slice(ASSISTANT_ENTITY_ICON_TYPE_ACTION_ID.length + 1) as AssistantIconType) + return + } if (action.id === ASSISTANT_ENTITY_DELETE_ACTION_ID) { void handleDeleteAssistant(item.id) } }, - [handleDeleteAssistant, handleToggleAssistantPin, isTagGrouping, openAssistantEditor, setAssistantSortType] + [ + handleDeleteAssistant, + handleToggleAssistantPin, + isTagGrouping, + openAssistantEditor, + setAssistantIconType, + setAssistantSortType + ] ) return ( diff --git a/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx b/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx index 3f8648ee227..53ee8258d8e 100644 --- a/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx +++ b/src/renderer/components/chat/resources/variants/ResourceEntityRail.tsx @@ -21,7 +21,7 @@ import { export type ResourceEntityRailItem = { id: string name: string - icon: ReactNode + icon?: ReactNode orderKey?: string /** * When true, a *visible* entity floats into the "已固定" section at the top and cannot be dragged. @@ -185,9 +185,11 @@ export function ResourceEntityRail handleItemClick(item)}> - - {item.icon} - + {item.icon && ( + + {item.icon} + + )} diff --git a/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx b/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx index 60d075c6126..f4713a9447d 100644 --- a/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx +++ b/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx @@ -90,6 +90,10 @@ vi.mock('@renderer/components/EmojiIcon', () => ({ default: ({ emoji }: { emoji: string }) => {emoji} })) +vi.mock('@renderer/components/Avatar/ModelAvatar', () => ({ + default: () => +})) + vi.mock('@renderer/components/resource/dialogs', () => ({ ResourceEditDialogHost: () => null })) @@ -115,41 +119,48 @@ vi.mock('@renderer/components/chat/resources/variants/ResourceEntityRail', () => headerActions?: ReactNode items: readonly ResourceEntityRailItem[] onContextMenuAction?: (item: ResourceEntityRailItem, action: ResolvedAction) => void | Promise - }) => ( -
- {headerActions} - {items.map((item) => { - const actions = getContextMenuActions?.(item) ?? [] - - return ( -
-
- {actions.map((action) => ( - - ))} -
-
- {actions.map((action) => ( - - ))} -
-
- ) - })} -
- ) + }) => { + const flattenActions = (actions: readonly ResolvedAction[]): readonly ResolvedAction[] => + actions.flatMap((action) => [action, ...flattenActions(action.children)]) + + return ( +
+ {headerActions} + {items.map((item) => { + const actions = getContextMenuActions?.(item) ?? [] + const renderedActions = flattenActions(actions) + + return ( +
+ {item.icon} +
+ {renderedActions.map((action) => ( + + ))} +
+
+ {renderedActions.map((action) => ( + + ))} +
+
+ ) + })} +
+ ) + } })) vi.mock('@renderer/hooks/resourceViewSources', () => ({ @@ -181,7 +192,9 @@ vi.mock('@renderer/hooks/useAssistant', () => ({ id: 'assistant-1', name: 'Assistant 1', orderKey: 'a', - emoji: 'A' + emoji: 'A', + modelId: 'openai::gpt-4o', + modelName: 'GPT-4o' } ], error: null, @@ -197,7 +210,9 @@ vi.mock('@renderer/hooks/agent/useAgent', () => ({ id: 'agent-1', name: 'Agent 1', orderKey: 'a', - configuration: {} + configuration: {}, + model: 'anthropic::claude-sonnet-4', + modelName: 'Claude Sonnet 4' } ], deleteAgent: agentDataMocks.deleteAgent, @@ -313,6 +328,18 @@ describe('classic layout entity resource list actions', () => { expect(preferenceMocks.setSortType).toHaveBeenCalledWith('tags') }) + it('lets the classic assistant rail switch icon display mode from the context menu', () => { + render( + + ) + + expect(screen.getByTestId('assistant-1-context-menu')).toHaveTextContent('assistants.icon.type') + + fireEvent.click(screen.getAllByRole('button', { name: 'settings.assistant.icon.type.model' })[0]) + + expect(preferenceMocks.setPreference).toHaveBeenCalledWith('assistant.icon_type', 'model') + }) + it('offers turning tag grouping off when already grouping (tags → list)', () => { preferenceMocks.sortType = 'tags' @@ -388,6 +415,23 @@ describe('classic layout entity resource list actions', () => { expect(onStartMissingAgentDraft).not.toHaveBeenCalled() }) + it('lets the classic agent rail switch icon display mode from the context menu', () => { + render( + + ) + + expect(screen.getByTestId('agent-1-context-menu')).toHaveTextContent('assistants.icon.type') + + fireEvent.click(screen.getAllByRole('button', { name: 'settings.assistant.icon.type.none' })[0]) + + expect(preferenceMocks.setPreference).toHaveBeenCalledWith('assistant.icon_type', 'none') + }) + it('lets the classic agent rail switch back to the workdir session view', () => { render( { requestAnimationFrameSpy.mockRestore() }) + it('does not reserve a leading slot for entities without icons', () => { + render( + + ) + + expect( + screen + .getByText('Assistant A') + .closest('[role="option"]') + ?.querySelector('[data-resource-list-leading-slot=true]') + ).toBeNull() + }) + it('splits pinned and non-pinned entities into two flush section headers while keeping avatars', () => { render( Date: Sat, 4 Jul 2026 13:56:25 +0800 Subject: [PATCH 15/40] refactor: add clear topics functionality for assistants and update related tests --- .../variants/AssistantResourceList.tsx | 90 +++++++++++++++++-- .../EntityResourceListActions.test.tsx | 85 ++++++++++++++++-- src/renderer/i18n/locales/en-us.json | 2 +- src/renderer/i18n/locales/zh-cn.json | 2 +- src/renderer/i18n/locales/zh-tw.json | 2 +- 5 files changed, 168 insertions(+), 13 deletions(-) diff --git a/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx b/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx index bcca29c0322..278f83679d6 100644 --- a/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx +++ b/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx @@ -13,7 +13,7 @@ import type { Topic } from '@renderer/types/topic' import { formatErrorMessageWithPrefix } from '@renderer/utils/error' import type { AssistantIconType } from '@shared/data/preference/preferenceTypes' import { isUniqueModelId, parseUniqueModelId } from '@shared/data/types/model' -import { Bot, Check, Edit3, PinIcon, PinOffIcon, Plus, Smile, Tags, Trash2 } from 'lucide-react' +import { Bot, BrushCleaning, Check, Edit3, PinIcon, PinOffIcon, Plus, Smile, Tags, Trash2 } from 'lucide-react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -27,6 +27,7 @@ const logger = loggerService.withContext('AssistantResourceList') const ASSISTANT_ENTITY_EDIT_ACTION_ID = 'assistant-entity.edit' const ASSISTANT_ENTITY_TOGGLE_PIN_ACTION_ID = 'assistant-entity.toggle-pin' +const ASSISTANT_ENTITY_CLEAR_TOPICS_ACTION_ID = 'assistant-entity.clear-topics' const ASSISTANT_ENTITY_TOGGLE_TAG_GROUPING_ACTION_ID = 'assistant-entity.toggle-tag-grouping' const ASSISTANT_ENTITY_ICON_TYPE_ACTION_ID = 'assistant-entity.icon-type' const ASSISTANT_ENTITY_DELETE_ACTION_ID = 'assistant-entity.delete' @@ -102,9 +103,10 @@ export function AssistantResourceList({ togglePin: toggleAssistantPin } = usePins('assistant') const { deleteAssistant } = useAssistantMutations() - const { refreshTopics } = useTopicMutations() + const { deleteTopicsByAssistantId, refreshTopics } = useTopicMutations() const topicPinnedIdSet = useMemo(() => new Set(topicPinnedIds), [topicPinnedIds]) const [deletingAssistantId, setDeletingAssistantId] = useState(null) + const [clearingTopicsAssistantId, setClearingTopicsAssistantId] = useState(null) const [editDialogTarget, setEditDialogTarget] = useState(null) const assistantPinnedIdSet = useMemo(() => new Set(assistantPinnedIds), [assistantPinnedIds]) const isAssistantPinActionDisabled = isAssistantPinsLoading || isAssistantPinsRefreshing || isAssistantPinsMutating @@ -201,6 +203,62 @@ export function AssistantResourceList({ [isAssistantPinActionDisabled, refreshAssistants, t, toggleAssistantPin] ) + const handleClearAssistantTopics = useCallback( + async (assistantId: string) => { + if (clearingTopicsAssistantId || deletingAssistantId) return + + const targetTopics = topics.filter((topic) => topic.assistantId === assistantId) + if (targetTopics.length === 0) return + + const targetTopicIds = new Set(targetTopics.map((topic) => topic.id)) + const remainingTopics = topics.filter((topic) => !targetTopicIds.has(topic.id)) + if (remainingTopics.length === 0) { + window.toast.error(t('chat.topics.manage.error.at_least_one')) + return + } + + setClearingTopicsAssistantId(assistantId) + try { + const confirmed = await window.modal.confirm({ + title: t('assistants.clear.title'), + content: t('assistants.clear.content'), + okText: t('common.delete'), + cancelText: t('common.cancel'), + centered: true, + okButtonProps: { + danger: true + } + }) + if (!confirmed) return + + const result = await deleteTopicsByAssistantId(assistantId) + const deletedIds = new Set(result.deletedIds) + const actualRemainingTopics = topics.filter((topic) => !deletedIds.has(topic.id)) + if (activeAssistantId === assistantId && actualRemainingTopics.length > 0) { + onSelectTopic(actualRemainingTopics[0]) + } + + window.toast.success(t('chat.topics.manage.delete.success', { count: result.deletedCount })) + await refreshTopics() + } catch (err) { + logger.error('Failed to clear assistant topics from classic-layout rail', { assistantId, err }) + window.toast.error(t('chat.topics.manage.delete.error')) + } finally { + setClearingTopicsAssistantId(null) + } + }, + [ + activeAssistantId, + clearingTopicsAssistantId, + deleteTopicsByAssistantId, + deletingAssistantId, + onSelectTopic, + refreshTopics, + t, + topics + ] + ) + const handleDeleteAssistant = useCallback( async (assistantId: string) => { if (deletingAssistantId) return @@ -268,11 +326,20 @@ export function AssistantResourceList({ availability: { visible: true, enabled: !isAssistantPinActionDisabled }, children: [] }, + { + id: ASSISTANT_ENTITY_CLEAR_TOPICS_ACTION_ID, + label: t('assistants.clear.menu_title'), + icon: , + order: 25, + danger: false, + availability: { visible: true, enabled: !clearingTopicsAssistantId && !deletingAssistantId }, + children: [] + }, { id: ASSISTANT_ENTITY_ICON_TYPE_ACTION_ID, label: t('assistants.icon.type'), icon: , - order: 25, + order: 30, danger: false, availability: { visible: true, enabled: true }, children: ASSISTANT_ICON_TYPE_OPTIONS.map((type) => ({ @@ -289,7 +356,7 @@ export function AssistantResourceList({ id: ASSISTANT_ENTITY_TOGGLE_TAG_GROUPING_ACTION_ID, label: isTagGrouping ? t('assistants.tags.ungroup') : t('assistants.tags.group_by'), icon: , - order: 30, + order: 35, danger: false, availability: { visible: true, enabled: true }, children: [] @@ -306,7 +373,15 @@ export function AssistantResourceList({ } ] }, - [assistantIconType, assistantPinnedIdSet, deletingAssistantId, isAssistantPinActionDisabled, isTagGrouping, t] + [ + assistantIconType, + assistantPinnedIdSet, + clearingTopicsAssistantId, + deletingAssistantId, + isAssistantPinActionDisabled, + isTagGrouping, + t + ] ) const handleContextMenuAction = useCallback( @@ -319,6 +394,10 @@ export function AssistantResourceList({ void handleToggleAssistantPin(item.id) return } + if (action.id === ASSISTANT_ENTITY_CLEAR_TOPICS_ACTION_ID) { + void handleClearAssistantTopics(item.id) + return + } if (action.id === ASSISTANT_ENTITY_TOGGLE_TAG_GROUPING_ACTION_ID) { void setAssistantSortType(isTagGrouping ? 'list' : 'tags') return @@ -333,6 +412,7 @@ export function AssistantResourceList({ }, [ handleDeleteAssistant, + handleClearAssistantTopics, handleToggleAssistantPin, isTagGrouping, openAssistantEditor, diff --git a/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx b/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx index f4713a9447d..8f43b046ded 100644 --- a/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx +++ b/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx @@ -1,6 +1,6 @@ import type { ResolvedAction } from '@renderer/components/chat/actions/actionTypes' import type { ResourceEntityRailItem } from '@renderer/components/chat/resources/variants/ResourceEntityRail' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -8,9 +8,14 @@ import { AgentResourceList } from '../AgentResourceList' import { AssistantResourceList } from '../AssistantResourceList' const assistantDataMocks = vi.hoisted(() => ({ + deleteTopicsByAssistantId: vi.fn(), deleteAssistant: vi.fn(), refreshTopics: vi.fn(), - refetchAssistants: vi.fn() + refetchAssistants: vi.fn(), + topics: [ + { id: 'topic-1', assistantId: 'assistant-1', name: 'Topic 1' }, + { id: 'topic-2', assistantId: 'assistant-2', name: 'Topic 2' } + ] })) const agentDataMocks = vi.hoisted(() => ({ @@ -178,7 +183,7 @@ vi.mock('@renderer/hooks/resourceViewSources', () => ({ error: null, isFullyLoaded: true, isLoadingAll: false, - topics: [{ id: 'topic-1', assistantId: 'assistant-1', name: 'Topic 1' }] + topics: assistantDataMocks.topics }) })) @@ -195,6 +200,14 @@ vi.mock('@renderer/hooks/useAssistant', () => ({ emoji: 'A', modelId: 'openai::gpt-4o', modelName: 'GPT-4o' + }, + { + id: 'assistant-2', + name: 'Assistant 2', + orderKey: 'b', + emoji: 'B', + modelId: 'openai::gpt-4o', + modelName: 'GPT-4o' } ], error: null, @@ -235,6 +248,7 @@ vi.mock('@renderer/hooks/usePins', () => ({ vi.mock('@renderer/hooks/useTopic', () => ({ mapApiTopicToRendererTopic: (topic: unknown) => topic, useTopicMutations: () => ({ + deleteTopicsByAssistantId: assistantDataMocks.deleteTopicsByAssistantId, refreshTopics: assistantDataMocks.refreshTopics }) })) @@ -267,11 +281,22 @@ describe('classic layout entity resource list actions', () => { preferenceMocks.values.clear() preferenceMocks.setPreference.mockClear() preferenceMocks.setSortType.mockClear() + assistantDataMocks.topics = [ + { id: 'topic-1', assistantId: 'assistant-1', name: 'Topic 1' }, + { id: 'topic-2', assistantId: 'assistant-2', name: 'Topic 2' } + ] + assistantDataMocks.deleteTopicsByAssistantId.mockResolvedValue({ deletedIds: ['topic-1'], deletedCount: 1 }) + assistantDataMocks.deleteTopicsByAssistantId.mockClear() assistantDataMocks.deleteAssistant.mockResolvedValue(undefined) + assistantDataMocks.deleteAssistant.mockClear() assistantDataMocks.refreshTopics.mockResolvedValue(undefined) + assistantDataMocks.refreshTopics.mockClear() assistantDataMocks.refetchAssistants.mockResolvedValue(undefined) + assistantDataMocks.refetchAssistants.mockClear() agentDataMocks.deleteAgent.mockResolvedValue(undefined) + agentDataMocks.deleteAgent.mockClear() agentDataMocks.refetchAgents.mockResolvedValue(undefined) + agentDataMocks.refetchAgents.mockClear() window.modal = { confirm: vi.fn().mockResolvedValue(true) @@ -297,8 +322,8 @@ describe('classic layout entity resource list actions', () => { expect(screen.getByTestId('assistant-1-context-menu')).toHaveTextContent('assistants.delete.title') expect(screen.getByTestId('assistant-1-more-menu')).toHaveTextContent('assistants.delete.title') - expect(screen.getByTestId('assistant-1-context-menu')).not.toHaveTextContent('assistants.clear.menu_title') - expect(screen.getByTestId('assistant-1-more-menu')).not.toHaveTextContent('assistants.clear.menu_title') + expect(screen.getByTestId('assistant-1-context-menu')).toHaveTextContent('assistants.clear.menu_title') + expect(screen.getByTestId('assistant-1-more-menu')).toHaveTextContent('assistants.clear.menu_title') fireEvent.click(screen.getAllByRole('button', { name: 'assistants.delete.title' })[0]) @@ -314,6 +339,56 @@ describe('classic layout entity resource list actions', () => { expect(onStartDraftAssistant).not.toHaveBeenCalled() }) + it('clears assistant topics from the classic layout assistant context menu', async () => { + const onSelectTopic = vi.fn() + + render( + + ) + + fireEvent.click( + within(screen.getByTestId('assistant-1-context-menu')).getByRole('button', { + name: 'assistants.clear.menu_title' + }) + ) + + await waitFor(() => + expect(window.modal.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'assistants.clear.content', + title: 'assistants.clear.title' + }) + ) + ) + await waitFor(() => expect(assistantDataMocks.deleteTopicsByAssistantId).toHaveBeenCalledWith('assistant-1')) + await waitFor(() => expect(assistantDataMocks.refreshTopics).toHaveBeenCalledTimes(1)) + expect(onSelectTopic).toHaveBeenCalledWith(expect.objectContaining({ id: 'topic-2' })) + expect(window.toast.success).toHaveBeenCalledWith('chat.topics.manage.delete.success') + }) + + it('keeps at least one topic when clearing classic assistant topics would delete all topics', async () => { + assistantDataMocks.topics = [{ id: 'topic-2', assistantId: 'assistant-2', name: 'Topic 2' }] + + render( + + ) + + fireEvent.click( + within(screen.getByTestId('assistant-2-context-menu')).getByRole('button', { + name: 'assistants.clear.menu_title' + }) + ) + + await waitFor(() => expect(window.toast.error).toHaveBeenCalledWith('chat.topics.manage.error.at_least_one')) + expect(window.modal.confirm).not.toHaveBeenCalled() + expect(assistantDataMocks.deleteTopicsByAssistantId).not.toHaveBeenCalled() + expect(assistantDataMocks.refreshTopics).not.toHaveBeenCalled() + }) + it('toggles assistant tag grouping from the context menu (list → tags)', () => { render( diff --git a/src/renderer/i18n/locales/en-us.json b/src/renderer/i18n/locales/en-us.json index fc1d62f4c42..a1e29f80839 100644 --- a/src/renderer/i18n/locales/en-us.json +++ b/src/renderer/i18n/locales/en-us.json @@ -1094,7 +1094,7 @@ "abbr": "Assistants", "clear": { "content": "Clearing conversations will delete all conversations and files in the assistant. Are you sure you want to continue?", - "menu_title": "Delete all assistant conversations", + "menu_title": "Clear Topics", "title": "Clear conversations" }, "copy": { diff --git a/src/renderer/i18n/locales/zh-cn.json b/src/renderer/i18n/locales/zh-cn.json index c0f300a0f30..b35d8d683e2 100644 --- a/src/renderer/i18n/locales/zh-cn.json +++ b/src/renderer/i18n/locales/zh-cn.json @@ -1094,7 +1094,7 @@ "abbr": "助手", "clear": { "content": "清空对话会删除助手下所有对话和文件,确定要继续吗?", - "menu_title": "删除助手下所有对话", + "menu_title": "清空话题", "title": "清空对话" }, "copy": { diff --git a/src/renderer/i18n/locales/zh-tw.json b/src/renderer/i18n/locales/zh-tw.json index 3f096656a52..6ffca76a42e 100644 --- a/src/renderer/i18n/locales/zh-tw.json +++ b/src/renderer/i18n/locales/zh-tw.json @@ -1094,7 +1094,7 @@ "abbr": "助手", "clear": { "content": "清空對話會刪除助手下所有對話和檔案,確定要繼續嗎?", - "menu_title": "刪除助手下所有對話", + "menu_title": "清空話題", "title": "清空對話" }, "copy": { From efd83755bb02bddf0d8e67a4df3a45cc9f833544 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sat, 4 Jul 2026 14:06:20 +0800 Subject: [PATCH 16/40] refactor: update assistant topic clearing success modal and related translations --- .../variants/AssistantResourceList.tsx | 12 +++++++++- .../EntityResourceListActions.test.tsx | 24 ++++++++++++++++--- src/renderer/i18n/locales/en-us.json | 7 +++++- src/renderer/i18n/locales/zh-cn.json | 7 +++++- src/renderer/i18n/locales/zh-tw.json | 7 +++++- 5 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx b/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx index 278f83679d6..7efa0d0cde2 100644 --- a/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx +++ b/src/renderer/components/chat/resources/variants/AssistantResourceList.tsx @@ -238,7 +238,17 @@ export function AssistantResourceList({ onSelectTopic(actualRemainingTopics[0]) } - window.toast.success(t('chat.topics.manage.delete.success', { count: result.deletedCount })) + void window.modal.success({ + title: t('assistants.clear.success_title', { count: result.deletedCount }), + content: ( +
+

{t('assistants.clear.success_content.line1')}

+

{t('assistants.clear.success_content.line2')}

+
+ ), + okText: t('common.i_know'), + centered: true + }) await refreshTopics() } catch (err) { logger.error('Failed to clear assistant topics from classic-layout rail', { assistantId, err }) diff --git a/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx b/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx index 8f43b046ded..28be2f814ef 100644 --- a/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx +++ b/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx @@ -51,7 +51,8 @@ vi.mock('@cherrystudio/ui', () => ({ vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string) => key + t: (key: string, options?: { count?: number }) => + key === 'assistants.clear.success_title' ? `${key}:${options?.count}` : key }) })) @@ -299,7 +300,8 @@ describe('classic layout entity resource list actions', () => { agentDataMocks.refetchAgents.mockClear() window.modal = { - confirm: vi.fn().mockResolvedValue(true) + confirm: vi.fn().mockResolvedValue(true), + success: vi.fn() } as unknown as typeof window.modal window.toast = { error: vi.fn(), @@ -367,7 +369,23 @@ describe('classic layout entity resource list actions', () => { await waitFor(() => expect(assistantDataMocks.deleteTopicsByAssistantId).toHaveBeenCalledWith('assistant-1')) await waitFor(() => expect(assistantDataMocks.refreshTopics).toHaveBeenCalledTimes(1)) expect(onSelectTopic).toHaveBeenCalledWith(expect.objectContaining({ id: 'topic-2' })) - expect(window.toast.success).toHaveBeenCalledWith('chat.topics.manage.delete.success') + expect(window.toast.success).not.toHaveBeenCalled() + expect(window.modal.success).toHaveBeenCalledWith( + expect.objectContaining({ + centered: true, + okText: 'common.i_know', + title: 'assistants.clear.success_title:1' + }) + ) + const successOptions = vi.mocked(window.modal.success).mock.calls[0][0] + expect(successOptions.content).toMatchObject({ + props: { + children: [ + expect.objectContaining({ props: { children: 'assistants.clear.success_content.line1' } }), + expect.objectContaining({ props: { children: 'assistants.clear.success_content.line2' } }) + ] + } + }) }) it('keeps at least one topic when clearing classic assistant topics would delete all topics', async () => { diff --git a/src/renderer/i18n/locales/en-us.json b/src/renderer/i18n/locales/en-us.json index a1e29f80839..a128aed0363 100644 --- a/src/renderer/i18n/locales/en-us.json +++ b/src/renderer/i18n/locales/en-us.json @@ -1093,8 +1093,13 @@ "assistants": { "abbr": "Assistants", "clear": { - "content": "Clearing conversations will delete all conversations and files in the assistant. Are you sure you want to continue?", + "content": "Clearing topics will delete all topics under this assistant. Are you sure you want to continue?", "menu_title": "Clear Topics", + "success_content": { + "line1": "This assistant has no topics now and will be hidden from the current list.", + "line2": "To view it, open Manage Assistants from the filter menu." + }, + "success_title": "Cleared {{count}} topics", "title": "Clear conversations" }, "copy": { diff --git a/src/renderer/i18n/locales/zh-cn.json b/src/renderer/i18n/locales/zh-cn.json index b35d8d683e2..8f67ac743a6 100644 --- a/src/renderer/i18n/locales/zh-cn.json +++ b/src/renderer/i18n/locales/zh-cn.json @@ -1093,8 +1093,13 @@ "assistants": { "abbr": "助手", "clear": { - "content": "清空对话会删除助手下所有对话和文件,确定要继续吗?", + "content": "清空话题会删除助手下所有话题,确定要继续吗?", "menu_title": "清空话题", + "success_content": { + "line1": "该助手已没有话题,会从当前列表隐藏。", + "line2": "如需查看,请在筛选菜单中打开“管理助手”。" + }, + "success_title": "已清空 {{count}} 个话题", "title": "清空对话" }, "copy": { diff --git a/src/renderer/i18n/locales/zh-tw.json b/src/renderer/i18n/locales/zh-tw.json index 6ffca76a42e..f6cd2d5342e 100644 --- a/src/renderer/i18n/locales/zh-tw.json +++ b/src/renderer/i18n/locales/zh-tw.json @@ -1093,8 +1093,13 @@ "assistants": { "abbr": "助手", "clear": { - "content": "清空對話會刪除助手下所有對話和檔案,確定要繼續嗎?", + "content": "清空話題會刪除助手下所有話題,確定要繼續嗎?", "menu_title": "清空話題", + "success_content": { + "line1": "該助手已沒有話題,會從目前列表隱藏。", + "line2": "如需查看,請在篩選選單中開啟「管理助手」。" + }, + "success_title": "已清空 {{count}} 個話題", "title": "清空對話" }, "copy": { From 5e45378eeb71749758607bd8f7db2ce285427175 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sat, 4 Jul 2026 16:12:46 +0800 Subject: [PATCH 17/40] refactor(chat-resources): flatten edit dialog tool tabs Signed-off-by: kangfenmao --- .../resources/variants/AgentResourceList.tsx | 2 +- .../EntityResourceListActions.test.tsx | 2 +- .../resource/dialogs/edit/AgentEditDialog.tsx | 1 + .../dialogs/edit/AssistantEditDialog.tsx | 5 +-- .../dialogs/edit/EditDialogShared.tsx | 35 +++++++++++++++---- .../edit/__tests__/EditDialogs.test.tsx | 25 ++----------- src/renderer/i18n/locales/en-us.json | 5 ++- src/renderer/i18n/locales/zh-cn.json | 5 ++- src/renderer/i18n/locales/zh-tw.json | 5 ++- 9 files changed, 49 insertions(+), 36 deletions(-) diff --git a/src/renderer/components/chat/resources/variants/AgentResourceList.tsx b/src/renderer/components/chat/resources/variants/AgentResourceList.tsx index 67178cee0a5..da5c0353f80 100644 --- a/src/renderer/components/chat/resources/variants/AgentResourceList.tsx +++ b/src/renderer/components/chat/resources/variants/AgentResourceList.tsx @@ -261,7 +261,7 @@ export function AgentResourceList({ }, { id: AGENT_ENTITY_ICON_TYPE_ACTION_ID, - label: t('assistants.icon.type'), + label: t('agent.icon.type'), icon: , order: 25, danger: false, diff --git a/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx b/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx index 28be2f814ef..5f1e0f36876 100644 --- a/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx +++ b/src/renderer/components/chat/resources/variants/__tests__/EntityResourceListActions.test.tsx @@ -518,7 +518,7 @@ describe('classic layout entity resource list actions', () => { /> ) - expect(screen.getByTestId('agent-1-context-menu')).toHaveTextContent('assistants.icon.type') + expect(screen.getByTestId('agent-1-context-menu')).toHaveTextContent('agent.icon.type') fireEvent.click(screen.getAllByRole('button', { name: 'settings.assistant.icon.type.none' })[0]) diff --git a/src/renderer/components/resource/dialogs/edit/AgentEditDialog.tsx b/src/renderer/components/resource/dialogs/edit/AgentEditDialog.tsx index 5425b2bd6c6..0084551bd6e 100644 --- a/src/renderer/components/resource/dialogs/edit/AgentEditDialog.tsx +++ b/src/renderer/components/resource/dialogs/edit/AgentEditDialog.tsx @@ -286,6 +286,7 @@ function AgentEditDialogContent({ open={open} rootError={rootError} setDialogContentElement={setDialogContentElement} + groupPresentation="inline" tabs={tabs} title={t('library.config.dialogs.edit.agent_title')}>