diff --git a/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts b/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts index 22567e631f9..5119ca54d69 100644 --- a/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts +++ b/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts @@ -1,6 +1,6 @@ /** * Auto-generated preference mappings from classification.json - * Generated at: 2026-06-24T07:22:09.467Z + * Generated at: 2026-07-05T13:08:15.693Z * * This file contains pure mapping relationships without default values. * Default values are managed in src/shared/data/preferences.ts @@ -150,6 +150,10 @@ export const REDUX_STORE_MAPPINGS = { originalKey: 'fontSize', targetKey: 'chat.message.font_size' }, + { + originalKey: 'topicPosition', + targetKey: 'topic.tab.position' + }, { originalKey: 'assistantIconType', targetKey: 'assistant.icon_type' @@ -795,11 +799,11 @@ export const LOCALSTORAGE_MAPPINGS: ReadonlyArray<{ originalKey: string; targetK /** * 映射统计: * - ElectronStore项: 1 - * - Redux Store项: 175 + * - Redux Store项: 176 * - Redux分类: settings, selectionStore, llm, nutstore, preprocess, translate, websearch, ocr, note * - DexieSettings项: 4 * - localStorage项: 0 - * - 总配置项: 180 + * - 总配置项: 181 * * 使用说明: * 1. ElectronStore读取: configManager.get(mapping.originalKey) diff --git a/src/main/services/file/__tests__/watcher.test.ts b/src/main/services/file/__tests__/watcher.test.ts index 13d1d65ca4f..9b0d10f2389 100644 --- a/src/main/services/file/__tests__/watcher.test.ts +++ b/src/main/services/file/__tests__/watcher.test.ts @@ -170,8 +170,9 @@ describe('createDirectoryWatcher', () => { const rootFile = path.join(dir, 'root.txt') as FilePath const nestedFile = path.join(nestedDir, 'nested.txt') as FilePath + const rootAdd = waitForEvent(w, (e) => e.kind === 'add' && e.path === rootFile) await writeFile(rootFile, 'root') - await waitForEvent(w, (e) => e.kind === 'add' && e.path === rootFile) + await rootAdd await writeFile(nestedFile, 'nested') await new Promise((r) => setTimeout(r, 400)) diff --git a/src/renderer/components/chat/panes/ArtifactPane.tsx b/src/renderer/components/chat/panes/ArtifactPane.tsx index be5b098d0b8..8f9e38a19cf 100644 --- a/src/renderer/components/chat/panes/ArtifactPane.tsx +++ b/src/renderer/components/chat/panes/ArtifactPane.tsx @@ -641,7 +641,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 c9ca5adcc0a..ea3067f18b1 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 ') @@ -113,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?.() @@ -136,6 +157,10 @@ function ShellProvider({ }) onOpenChangeRef.current?.(true) }, []) + const minimize = useCallback(() => { + setPdfLayoutPending(false) + setMaximized(false) + }, []) const toggleMaximized = useCallback(() => { setPdfLayoutPending(false) setMaximized((currentMaximized) => !currentMaximized) @@ -150,8 +175,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 +377,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 3e42749ce2b..619af3c493c 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') @@ -326,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( @@ -382,6 +427,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/panes/Shell/resourcePane.tsx b/src/renderer/components/chat/panes/Shell/resourcePane.tsx index e7b99510e96..f1b252e414b 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/resourceList/AgentResourceList.tsx b/src/renderer/components/chat/resourceList/AgentResourceList.tsx index 14179b83c8a..c04cfa10850 100644 --- a/src/renderer/components/chat/resourceList/AgentResourceList.tsx +++ b/src/renderer/components/chat/resourceList/AgentResourceList.tsx @@ -1,6 +1,6 @@ +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' import { ResourceEditDialogHost, type ResourceEditDialogTarget @@ -9,14 +9,20 @@ import { useMutation } from '@renderer/data/hooks/useDataApi' import { useAgents } from '@renderer/hooks/agent/useAgent' import { useAgentSessionsSource } from '@renderer/hooks/resourceViewSources' 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 { Pin, PinOff, Plus, Smile, SquarePen, Trash2 } from 'lucide-react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import type { ConversationResourceMenuItem } from './base' +import { + buildResolvedIconTypeMenuAction, + buildResolvedResourceEntityMenuAction, + type ConversationResourceMenuItem, + renderAgentEntityIcon, + SessionListOptionsMenu +} from './base' import { ResourceEntityRail, type ResourceEntityRailItem } from './ResourceEntityRail' import { sortResourceItemsByPinnedTime } from './resourceEntitySort' import { type ResourceEntityRailReorderAnchor, useResourceEntityRail } from './useResourceEntityRail' @@ -25,6 +31,7 @@ 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' type SessionListItem = AgentSessionEntity & { @@ -36,6 +43,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[] @@ -52,12 +60,17 @@ export function AgentResourceList({ onAddAgent, onOpenHistoryRecords, onSelectSession, + onSelectedAgentClick, onStartDraftAgent, onStartMissingAgentDraft, resourceMenuItems, onActiveAgentDeleted }: AgentResourceListProps) { const { t } = useTranslation() + // Agent rail icon style is stored under its own key so it no longer mutates the assistant's. + const [assistantIconType, setAssistantIconType] = usePreference('agent.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 { sessions, @@ -82,6 +95,9 @@ export function AgentResourceList({ const { trigger: reorderAgent } = useMutation('PATCH', '/agents/:id/order', { refresh: ['/agents'] }) const [deletingAgentId, setDeletingAgentId] = useState(null) const [editDialogTarget, setEditDialogTarget] = useState(null) + 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') const agentPinnedIdSet = useMemo(() => new Set(agentPinnedIds), [agentPinnedIds]) const isAgentPinActionDisabled = isAgentPinsLoading || isAgentPinsRefreshing || isAgentPinsMutating const sessionItems = useMemo( @@ -91,21 +107,18 @@ 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 icon = renderAgentEntityIcon(assistantIconType, agent, defaultModelId) + + return { + id: agent.id, + name: agent.name, + orderKey: agent.orderKey, + pinned: agentPinnedIdSet.has(agent.id), + icon + } + }), + [agentPinnedIdSet, agents, assistantIconType, defaultModelId] ) const sortSessionsForEntity = useCallback( @@ -206,37 +219,39 @@ export function AgentResourceList({ const pinned = agentPinnedIdSet.has(item.id) return [ - { + buildResolvedResourceEntityMenuAction({ id: AGENT_ENTITY_EDIT_ACTION_ID, label: t('agent.edit.title'), icon: , - order: 10, - danger: false, - availability: { visible: true, enabled: true }, - children: [] - }, - { + order: 10 + }), + buildResolvedResourceEntityMenuAction({ id: AGENT_ENTITY_TOGGLE_PIN_ACTION_ID, label: pinned ? t('agent.unpin.title') : t('agent.pin.title'), icon: pinned ? : , order: 20, - danger: false, - availability: { visible: true, enabled: !isAgentPinActionDisabled }, - children: [] - }, - { + availability: { visible: true, enabled: !isAgentPinActionDisabled } + }), + buildResolvedIconTypeMenuAction( + AGENT_ENTITY_ICON_TYPE_ACTION_ID, + t('agent.icon.type'), + , + 25, + assistantIconType, + t + ), + buildResolvedResourceEntityMenuAction({ id: AGENT_ENTITY_DELETE_ACTION_ID, label: t('agent.delete.title'), icon: , group: 'danger', order: 30, danger: true, - availability: { visible: true, enabled: deletingAgentId === null }, - children: [] - } + availability: { visible: true, enabled: deletingAgentId === null } + }) ] }, - [agentPinnedIdSet, deletingAgentId, isAgentPinActionDisabled, t] + [agentPinnedIdSet, assistantIconType, deletingAgentId, isAgentPinActionDisabled, t] ) const handleContextMenuAction = useCallback( @@ -249,11 +264,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 ( @@ -261,16 +280,28 @@ export function AgentResourceList({ } addLabel={t('agent.add.title')} onAdd={onAddAgent ?? (() => onStartMissingAgentDraft?.())} - onOpenHistoryRecords={onOpenHistoryRecords} - resourceMenuItems={resourceMenuItems} + headerActions={ + void setSessionDisplayMode(nextMode)} + onManageAgents={manageAgentsMenuItem?.onSelect} + onManageSkills={manageSkillsMenuItem?.onSelect} + onOpenHistoryRecords={onOpenHistoryRecords} + /> + } onSelect={handleSelect} + onSelectedClick={() => void onSelectedAgentClick?.()} onReorder={handleReorder} getContextMenuActions={getContextMenuActions} onContextMenuAction={handleContextMenuAction} diff --git a/src/renderer/components/chat/resourceList/AssistantResourceList.tsx b/src/renderer/components/chat/resourceList/AssistantResourceList.tsx index e699de23900..67c904993ba 100644 --- a/src/renderer/components/chat/resourceList/AssistantResourceList.tsx +++ b/src/renderer/components/chat/resourceList/AssistantResourceList.tsx @@ -1,7 +1,6 @@ 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' import { ResourceEditDialogHost, type ResourceEditDialogTarget @@ -13,11 +12,19 @@ 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 { useCallback, useMemo, useState } from 'react' +import type { AssistantIconType } from '@shared/data/preference/preferenceTypes' +import { DEFAULT_ASSISTANT_EMOJI } from '@shared/data/presets/defaultAssistant' +import { BrushCleaning, Edit3, PinIcon, PinOffIcon, Plus, Smile, Tags, Trash2 } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import type { ConversationResourceMenuItem } from './base' +import { + buildResolvedIconTypeMenuAction, + buildResolvedResourceEntityMenuAction, + type ConversationResourceMenuItem, + renderAssistantEntityIcon, + TopicListOptionsMenu +} from './base' import { ResourceEntityRail, type ResourceEntityRailItem } from './ResourceEntityRail' import { sortResourceItemsByPinnedTime } from './resourceEntitySort' import { type ResourceEntityRailReorderAnchor, useResourceEntityRail } from './useResourceEntityRail' @@ -26,14 +33,19 @@ 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' +const DEFAULT_ASSISTANT_ENTITY_ID = 'assistant-entity:default' type AssistantResourceListProps = { activeAssistantId?: string | null onAddAssistant?: () => void | Promise onOpenHistoryRecords?: () => void onSelectTopic: (topic: Topic) => void | boolean + onCreateTopicAfterClear?: (assistantId: string) => void | Promise + onSelectedAssistantClick?: () => void | Promise onStartDraftAssistant: (assistantId: string | null) => void | Promise resourceMenuItems?: readonly ConversationResourceMenuItem[] /** @@ -49,13 +61,20 @@ export function AssistantResourceList({ onAddAssistant, onOpenHistoryRecords, onSelectTopic, + onCreateTopicAfterClear, + onSelectedAssistantClick, onStartDraftAssistant, resourceMenuItems, onActiveAssistantDeleted }: 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 hasActiveResourceMenuItem = resourceMenuItems?.some((item) => item.active) ?? false + const manageAssistantsMenuItem = resourceMenuItems?.find((item) => item.id === 'assistant-resource-view') const { assistants, isLoading: isAssistantsLoading, @@ -77,9 +96,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 @@ -91,34 +111,65 @@ export function AssistantResourceList({ })), [apiTopics, topicPinnedIdSet] ) + const topicsRef = useRef(topics) + useEffect(() => { + topicsRef.current = topics + }, [topics]) - 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 ? ( - - ) : ( - - - + const entities = useMemo(() => { + const hasDefaultAssistantTopics = topics.some((topic) => !topic.assistantId) + const defaultAssistantEntity: ResourceEntityRailItem[] = hasDefaultAssistantTopics + ? [ + { + id: DEFAULT_ASSISTANT_ENTITY_ID, + name: t('chat.default.name'), + icon: renderAssistantEntityIcon( + assistantIconType, + { + emoji: DEFAULT_ASSISTANT_EMOJI + }, + defaultModelId + ), + reorderable: false + } + ] + : [] + + return [ + ...assistants.map((assistant) => { + const icon = renderAssistantEntityIcon( + assistantIconType, + { + emoji: assistant.emoji, + modelId: assistant.modelId, + modelName: assistant.modelName + }, + defaultModelId ) - })), - [assistants, assistantPinnedIdSet] - ) + + return { + id: assistant.id, + name: assistant.name, + orderKey: assistant.orderKey, + pinned: assistantPinnedIdSet.has(assistant.id), + tag: assistant.tags?.[0]?.name, + icon + } + }), + ...defaultAssistantEntity + ] + }, [assistantIconType, assistants, assistantPinnedIdSet, defaultModelId, t, topics]) const sortTopicsForEntity = useCallback( (entityTopics: Topic[]) => sortResourceItemsByPinnedTime(entityTopics, new Date()), [] ) - const getTopicAssistantId = useCallback((topic: Topic) => topic.assistantId, []) + const getTopicAssistantId = useCallback((topic: Topic) => topic.assistantId ?? DEFAULT_ASSISTANT_ENTITY_ID, []) const { trigger: reorderAssistantOrder } = useMutation('PATCH', '/assistants/:id/order', { refresh: ['/assistants'] }) const reorderAssistant = useCallback( async (assistantId: string, anchor: ResourceEntityRailReorderAnchor) => { + if (assistantId === DEFAULT_ASSISTANT_ENTITY_ID) return + await reorderAssistantOrder({ params: { id: assistantId }, body: anchor }) }, [reorderAssistantOrder] @@ -131,16 +182,20 @@ export function AssistantResourceList({ [t] ) + const handleStartDraftAssistant = useCallback( + (assistantId: string) => onStartDraftAssistant(assistantId === DEFAULT_ASSISTANT_ENTITY_ID ? null : assistantId), + [onStartDraftAssistant] + ) const { items, listStatus, selectedId, handleSelect, handleReorder } = useResourceEntityRail({ entities, resources: topics, getResourceParentId: getTopicAssistantId, - activeEntityId: activeAssistantId, + activeEntityId: activeAssistantId ?? DEFAULT_ASSISTANT_ENTITY_ID, isLoading: isAssistantsLoading || isTopicsLoadingAll || !isTopicsFullyLoaded || isTopicPinsLoading, isError: !!(assistantsError || topicsError), sortResourcesForEntity: sortTopicsForEntity, onPickResource: onSelectTopic, - onStartDraft: onStartDraftAssistant, + onStartDraft: handleStartDraftAssistant, reorder: reorderAssistant, refetchEntities: refreshAssistants, onReorderError: handleReorderError @@ -165,6 +220,57 @@ export function AssistantResourceList({ [isAssistantPinActionDisabled, refreshAssistants, t, toggleAssistantPin] ) + const handleClearAssistantTopics = useCallback( + async (assistantId: string) => { + if (clearingTopicsAssistantId || deletingAssistantId) return + + const targetTopics = topicsRef.current.filter((topic) => topic.assistantId === assistantId) + if (targetTopics.length === 0) 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 + + // Re-validate against the latest topics after the confirm dialog: the list may + // have changed while it was open, and TopicService.deleteByAssistantId() has no + // at-least-one guard of its own, so bail out if nothing is left to clear. + const latestTargetTopicIds = new Set( + topicsRef.current.filter((topic) => topic.assistantId === assistantId).map((topic) => topic.id) + ) + if (latestTargetTopicIds.size === 0) return + + const result = await deleteTopicsByAssistantId(assistantId) + await refreshTopics() + await onCreateTopicAfterClear?.(assistantId) + + window.toast.success(t('assistants.clear.success_title', { count: result.deletedCount })) + } 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) + } + }, + [ + clearingTopicsAssistantId, + deleteTopicsByAssistantId, + deletingAssistantId, + onCreateTopicAfterClear, + refreshTopics, + t + ] + ) + const handleDeleteAssistant = useCallback( async (assistantId: string) => { if (deletingAssistantId) return @@ -211,53 +317,93 @@ export function AssistantResourceList({ const getContextMenuActions = useCallback( (item: ResourceEntityRailItem): ResolvedAction[] => { + if (item.id === DEFAULT_ASSISTANT_ENTITY_ID) { + return [ + buildResolvedIconTypeMenuAction( + ASSISTANT_ENTITY_ICON_TYPE_ACTION_ID, + t('assistants.icon.type'), + , + 30, + assistantIconType, + t + ), + buildResolvedResourceEntityMenuAction({ + id: ASSISTANT_ENTITY_TOGGLE_TAG_GROUPING_ACTION_ID, + label: isTagGrouping ? t('assistants.tags.ungroup') : t('assistants.tags.group_by'), + icon: , + order: 35 + }) + ] + } + const pinned = assistantPinnedIdSet.has(item.id) return [ - { + buildResolvedResourceEntityMenuAction({ id: ASSISTANT_ENTITY_EDIT_ACTION_ID, label: t('assistants.edit.title'), icon: , - order: 10, - danger: false, - availability: { visible: true, enabled: true }, - children: [] - }, - { + order: 10 + }), + buildResolvedResourceEntityMenuAction({ id: ASSISTANT_ENTITY_TOGGLE_PIN_ACTION_ID, label: pinned ? t('assistants.unpin.title') : t('assistants.pin.title'), icon: pinned ? : , order: 20, - danger: false, - availability: { visible: true, enabled: !isAssistantPinActionDisabled }, - children: [] - }, - { + availability: { visible: true, enabled: !isAssistantPinActionDisabled } + }), + buildResolvedResourceEntityMenuAction({ + id: ASSISTANT_ENTITY_CLEAR_TOPICS_ACTION_ID, + label: t('assistants.clear.menu_title'), + icon: , + order: 25, + availability: { visible: true, enabled: !clearingTopicsAssistantId && !deletingAssistantId } + }), + buildResolvedIconTypeMenuAction( + ASSISTANT_ENTITY_ICON_TYPE_ACTION_ID, + t('assistants.icon.type'), + , + 30, + assistantIconType, + t + ), + buildResolvedResourceEntityMenuAction({ id: ASSISTANT_ENTITY_TOGGLE_TAG_GROUPING_ACTION_ID, label: isTagGrouping ? t('assistants.tags.ungroup') : t('assistants.tags.group_by'), icon: , - order: 25, - danger: false, - availability: { visible: true, enabled: true }, - children: [] - }, - { + order: 35 + }), + buildResolvedResourceEntityMenuAction({ id: ASSISTANT_ENTITY_DELETE_ACTION_ID, label: t('assistants.delete.title'), icon: , group: 'danger', order: 30, danger: true, - availability: { visible: true, enabled: deletingAssistantId === null }, - children: [] - } + availability: { visible: true, enabled: deletingAssistantId === null } + }) ] }, - [assistantPinnedIdSet, deletingAssistantId, isAssistantPinActionDisabled, isTagGrouping, t] + [ + assistantIconType, + assistantPinnedIdSet, + clearingTopicsAssistantId, + deletingAssistantId, + isAssistantPinActionDisabled, + isTagGrouping, + t + ] ) const handleContextMenuAction = useCallback( (item: ResourceEntityRailItem, action: ResolvedAction) => { + if (item.id === DEFAULT_ASSISTANT_ENTITY_ID && !action.id.startsWith(ASSISTANT_ENTITY_ICON_TYPE_ACTION_ID)) { + if (action.id === ASSISTANT_ENTITY_TOGGLE_TAG_GROUPING_ACTION_ID) { + void setAssistantSortType(isTagGrouping ? 'list' : 'tags') + } + return + } + if (action.id === ASSISTANT_ENTITY_EDIT_ACTION_ID) { openAssistantEditor(item.id) return @@ -266,15 +412,31 @@ 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 } + 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, + handleClearAssistantTopics, + handleToggleAssistantPin, + isTagGrouping, + openAssistantEditor, + setAssistantIconType, + setAssistantSortType + ] ) return ( @@ -282,7 +444,8 @@ export function AssistantResourceList({ } addLabel={t('chat.add.assistant.title')} onAdd={onAddAssistant ?? (() => onStartDraftAssistant(null))} - onOpenHistoryRecords={onOpenHistoryRecords} - resourceMenuItems={resourceMenuItems} + headerActions={ + void setTopicDisplayMode(nextMode)} + onManageAssistants={manageAssistantsMenuItem?.onSelect} + onOpenHistoryRecords={onOpenHistoryRecords} + /> + } onSelect={handleSelect} - onReorder={handleReorder} + onSelectedClick={() => void onSelectedAssistantClick?.()} + // Reorder persists the global assistant `orderKey`; tag grouping only scopes drops + // visually, so dragging within a tag would still move the assistant in the global + // order. Disable reorder while grouping by tag until a tag-scoped ordering exists. + onReorder={isTagGrouping ? undefined : handleReorder} getContextMenuActions={getContextMenuActions} onContextMenuAction={handleContextMenuAction} /> diff --git a/src/renderer/components/chat/resourceList/ResourceEntityRail.tsx b/src/renderer/components/chat/resourceList/ResourceEntityRail.tsx index f751b679ee2..9b7d9ac79be 100644 --- a/src/renderer/components/chat/resourceList/ResourceEntityRail.tsx +++ b/src/renderer/components/chat/resourceList/ResourceEntityRail.tsx @@ -22,8 +22,9 @@ import { export type ResourceEntityRailItem = { id: string name: string - icon: ReactNode + icon?: ReactNode orderKey?: string + reorderable?: boolean /** * When true, a *visible* entity floats into the "已固定" section at the top and cannot be dragged. * It does not affect visibility — an entity with no resources stays hidden whether pinned or not. @@ -71,12 +72,13 @@ export type ResourceEntityRailProps readonly ResolvedAction[] + headerActions?: ReactNode listRef?: RefObject onAdd: () => void | Promise /** When provided, a history-records button sits next to the add button. */ @@ -85,6 +87,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' @@ -118,6 +122,7 @@ export function ResourceEntityRail) { 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 + const effectiveSelectedId = hasActiveResourceMenuItem ? null : selectedId + const effectiveSelectedClickId = hasActiveResourceMenuItem ? null : (selectedClickId ?? selectedId) + const handleItemClick = useCallback( + (item: T) => { + if (effectiveSelectedClickId === item.id && onSelectedClick) { + void onSelectedClick(item) + return + } + void onSelect(item) + }, + [effectiveSelectedClickId, onSelect, onSelectedClick] + ) + // Keyboard activation (Enter/Space) goes through the list's `selectItem` action, not the row's + // onClick, so route it back through `handleItemClick` to keep keyboard and mouse in sync — + // including the "activate the already-selected entity to toggle its pane" behavior. + const handleSelectItemById = useCallback( + (id: string) => { + const item = items.find((entry) => entry.id === id) + if (item) handleItemClick(item) + }, + [handleItemClick, items] + ) const runContextMenuAction = useCallback( (item: T, action: ResolvedAction) => { if (!action.availability.enabled || !onContextMenuAction) return @@ -168,11 +195,16 @@ export function ResourceEntityRail runContextMenuAction(item, action)) : [] + // No row onClick: selection for mouse, row-Enter, and listbox-keyboard all funnel through + // the list's selectItem action → onSelectItem (handleSelectItemById → handleItemClick), so + // every path stays consistent and fires exactly once. const row = ( - void onSelect(item)}> - - {item.icon} - + + {item.icon && ( + + {item.icon} + + )} @@ -209,7 +241,7 @@ export function ResourceEntityRail ) }, - [getContextMenuActions, onContextMenuAction, onSelect, runContextMenuAction, t] + [getContextMenuActions, onContextMenuAction, runContextMenuAction, t] ) const empty = useMemo(() => emptyFallback ??
, [emptyFallback]) const providerItems = useMemo( @@ -256,7 +288,8 @@ export function ResourceEntityRail reorderEnabled && !item.pinned} - canDropItem={({ activeItem, targetGroupId }) => - reorderEnabled && !activeItem.pinned && targetGroupId !== ENTITY_RAIL_PINNED_GROUP_ID + canDragItem={({ item }) => reorderEnabled && item.reorderable !== false && !item.pinned} + canDropItem={({ activeItem, sourceGroupId, targetGroupId }) => + reorderEnabled && + activeItem.reorderable !== false && + !activeItem.pinned && + targetGroupId !== ENTITY_RAIL_PINNED_GROUP_ID && + sourceGroupId === targetGroupId } onReorder={reorderEnabled ? onReorder : undefined}> @@ -281,15 +318,20 @@ export function ResourceEntityRail void onAdd()} actions={ - onOpenHistoryRecords ? ( - - onOpenHistoryRecords()}> - - - + headerActions || onOpenHistoryRecords ? ( + <> + {headerActions} + {onOpenHistoryRecords && ( + + onOpenHistoryRecords()}> + + + + )} + ) : undefined } /> diff --git a/src/renderer/components/chat/resourceList/__tests__/EntityResourceListActions.test.tsx b/src/renderer/components/chat/resourceList/__tests__/EntityResourceListActions.test.tsx index ab08f7af711..f8fc64a1e42 100644 --- a/src/renderer/components/chat/resourceList/__tests__/EntityResourceListActions.test.tsx +++ b/src/renderer/components/chat/resourceList/__tests__/EntityResourceListActions.test.tsx @@ -1,15 +1,21 @@ import type { ResolvedAction } from '@renderer/components/chat/actions/actionTypes' import type { ResourceEntityRailItem } from '@renderer/components/chat/resourceList/ResourceEntityRail' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' 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' } + ] as Array<{ id: string; assistantId?: string; name: string }> })) const agentDataMocks = vi.hoisted(() => ({ @@ -18,18 +24,62 @@ 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', () => ({ useTranslation: () => ({ - t: (key: string) => key + t: (key: string, options?: { count?: number }) => + key === 'assistants.clear.success_title' ? `${key}:${options?.count}` : key }) })) 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', () => ({ @@ -46,63 +96,97 @@ vi.mock('@renderer/components/EmojiIcon', () => ({ default: ({ emoji }: { emoji: string }) => {emoji} })) +vi.mock('@renderer/components/Avatar/ModelAvatar', () => ({ + default: () => +})) + vi.mock('@renderer/components/resourceCatalog/dialogs/edit', () => ({ ResourceEditDialogHost: () => null })) vi.mock('@renderer/components/chat/resourceList/useResourceEntityRail', () => ({ - useResourceEntityRail: ({ entities }: { entities: ResourceEntityRailItem[] }) => ({ + useResourceEntityRail: ({ + activeEntityId, + entities + }: { + activeEntityId?: string | null + entities: ResourceEntityRailItem[] + }) => ({ handleReorder: vi.fn(), handleSelect: vi.fn(), items: entities, listStatus: 'idle', - selectedId: null + selectedId: activeEntityId ?? null }) })) vi.mock('@renderer/components/chat/resourceList/ResourceEntityRail', () => ({ ResourceEntityRail: ({ getContextMenuActions, + groupByTag, + headerActions, items, - onContextMenuAction + onContextMenuAction, + onReorder, + resourceMenuItems, + selectedId }: { getContextMenuActions?: (item: ResourceEntityRailItem) => readonly ResolvedAction[] + groupByTag?: boolean + headerActions?: ReactNode items: readonly ResourceEntityRailItem[] onContextMenuAction?: (item: ResourceEntityRailItem, action: ResolvedAction) => void | Promise - }) => ( -
- {items.map((item) => { - const actions = getContextMenuActions?.(item) ?? [] - - return ( -
-
- {actions.map((action) => ( - - ))} -
-
- {actions.map((action) => ( - - ))} -
-
- ) - })} -
- ) + onReorder?: unknown + resourceMenuItems?: readonly { active?: boolean; id: string }[] + selectedId?: string | null + }) => { + const flattenActions = (actions: readonly ResolvedAction[]): readonly ResolvedAction[] => + actions.flatMap((action) => [action, ...flattenActions(action.children)]) + const hasActiveResourceMenuItem = resourceMenuItems?.some((item) => item.active) ?? false + + 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', () => ({ @@ -120,7 +204,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 }) })) @@ -134,7 +218,17 @@ vi.mock('@renderer/hooks/useAssistant', () => ({ id: 'assistant-1', name: 'Assistant 1', orderKey: 'a', - emoji: 'A' + 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, @@ -150,7 +244,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, @@ -173,6 +269,7 @@ vi.mock('@renderer/hooks/usePins', () => ({ vi.mock('@renderer/hooks/useTopic', () => ({ mapApiTopicToRendererTopic: (topic: unknown) => topic, useTopicMutations: () => ({ + deleteTopicsByAssistantId: assistantDataMocks.deleteTopicsByAssistantId, refreshTopics: assistantDataMocks.refreshTopics }) })) @@ -202,15 +299,29 @@ 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.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) + confirm: vi.fn().mockResolvedValue(true), + success: vi.fn() } as unknown as typeof window.modal window.toast = { error: vi.fn(), @@ -233,8 +344,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]) @@ -250,6 +361,152 @@ 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() + const onCreateTopicAfterClear = 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(onCreateTopicAfterClear).toHaveBeenCalledWith('assistant-1') + expect(onSelectTopic).not.toHaveBeenCalled() + expect(window.toast.success).toHaveBeenCalledWith('assistants.clear.success_title:1') + expect(window.modal.success).not.toHaveBeenCalled() + }) + + it('does not clear assistant topics when the list empties while the confirm dialog is open', async () => { + assistantDataMocks.topics = [ + { id: 'topic-1', assistantId: 'assistant-1', name: 'Topic 1' }, + { id: 'topic-2', assistantId: 'assistant-2', name: 'Topic 2' } + ] + let resolveConfirm!: (value: boolean) => void + const confirmPromise = new Promise((resolve) => { + resolveConfirm = resolve + }) + window.modal.confirm = vi.fn().mockReturnValue(confirmPromise) + const onCreateTopicAfterClear = vi.fn() + + const props = { + activeAssistantId: 'assistant-1', + onSelectTopic: vi.fn(), + onCreateTopicAfterClear, + onStartDraftAssistant: vi.fn() + } + const { rerender } = render() + + fireEvent.click( + within(screen.getByTestId('assistant-1-context-menu')).getByRole('button', { + name: 'assistants.clear.menu_title' + }) + ) + await waitFor(() => expect(window.modal.confirm).toHaveBeenCalledTimes(1)) + + // While the confirm dialog is open the topic list drains (e.g. cleared elsewhere). + // Re-render so the rail sees the latest topics before the user confirms. + assistantDataMocks.topics = [{ id: 'topic-2', assistantId: 'assistant-2', name: 'Topic 2' }] + rerender() + + await act(async () => { + resolveConfirm(true) + await confirmPromise + }) + + expect(assistantDataMocks.deleteTopicsByAssistantId).not.toHaveBeenCalled() + expect(assistantDataMocks.refreshTopics).not.toHaveBeenCalled() + expect(onCreateTopicAfterClear).not.toHaveBeenCalled() + expect(window.toast.success).not.toHaveBeenCalled() + }) + + it('keeps the default assistant visible in the classic assistant rail', () => { + assistantDataMocks.topics = [ + { id: 'topic-default', name: 'Default topic' }, + { id: 'topic-1', assistantId: 'assistant-1', name: 'Topic 1' } + ] + + render() + + const defaultAssistantRegion = screen.getByRole('region', { name: 'chat.default.name' }) + const assistantRegion = screen.getByRole('region', { name: 'Assistant 1' }) + + expect(defaultAssistantRegion).toBeInTheDocument() + expect(assistantRegion).toBeInTheDocument() + expect( + assistantRegion.compareDocumentPosition(defaultAssistantRegion) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + expect(screen.getByTestId('assistant-entity:default-context-menu')).not.toHaveTextContent('assistants.edit.title') + expect(screen.getByTestId('assistant-entity:default-context-menu')).not.toHaveTextContent('assistants.delete.title') + expect(screen.getByTestId('assistant-entity:default-context-menu')).not.toHaveTextContent( + 'assistants.clear.menu_title' + ) + }) + + it('creates a fresh topic after clearing the only classic assistant topics', async () => { + assistantDataMocks.topics = [{ id: 'topic-2', assistantId: 'assistant-2', name: 'Topic 2' }] + assistantDataMocks.deleteTopicsByAssistantId.mockResolvedValueOnce({ deletedIds: ['topic-2'], deletedCount: 1 }) + const onCreateTopicAfterClear = vi.fn() + + render( + + ) + + fireEvent.click( + within(screen.getByTestId('assistant-2-context-menu')).getByRole('button', { + name: 'assistants.clear.menu_title' + }) + ) + + await waitFor(() => expect(window.modal.confirm).toHaveBeenCalled()) + await waitFor(() => expect(assistantDataMocks.deleteTopicsByAssistantId).toHaveBeenCalledWith('assistant-2')) + await waitFor(() => expect(assistantDataMocks.refreshTopics).toHaveBeenCalledTimes(1)) + expect(onCreateTopicAfterClear).toHaveBeenCalledWith('assistant-2') + expect(window.toast.error).not.toHaveBeenCalled() + }) + + it('disables classic assistant rail reorder while grouping by tag', () => { + const props = { activeAssistantId: 'assistant-1', onSelectTopic: vi.fn(), onStartDraftAssistant: vi.fn() } + + preferenceMocks.sortType = 'list' + const { rerender } = render() + const railInList = screen.getByTestId('resource-entity-rail') + expect(railInList).toHaveAttribute('data-group-by-tag', 'false') + expect(railInList).toHaveAttribute('data-reorder', 'enabled') + + // Reorder persists the global assistant orderKey, so it must be disabled under tag + // grouping to avoid moving assistants across unrelated tags in the global order. + preferenceMocks.sortType = 'tags' + rerender() + const railInTags = screen.getByTestId('resource-entity-rail') + expect(railInTags).toHaveAttribute('data-group-by-tag', 'true') + expect(railInTags).toHaveAttribute('data-reorder', 'disabled') + }) + it('toggles assistant tag grouping from the context menu (list → tags)', () => { render( @@ -264,6 +521,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' @@ -277,6 +546,61 @@ 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', async () => { + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'chat.topics.display.time' })) + + await waitFor(() => { + 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('keeps assistant management in the shared display menu without adding a classic rail entry', () => { + const onManageAssistants = vi.fn() + + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'assistants.presets.manage.title' })) + + expect(onManageAssistants).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('resource-entity-rail')).toHaveAttribute('data-active-resource-menu', 'false') + expect(screen.getByTestId('resource-entity-rail')).toHaveAttribute('data-selected-id', '') + }) + 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 +635,109 @@ 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 icon display mode from the context menu', () => { + render( + + ) + + expect(screen.getByTestId('agent-1-context-menu')).toHaveTextContent('agent.icon.type') + + fireEvent.click(screen.getAllByRole('button', { name: 'settings.assistant.icon.type.none' })[0]) + + expect(preferenceMocks.setPreference).toHaveBeenCalledWith('agent.icon_type', 'none') + }) + + it('lets the classic agent rail switch back to the workdir session view', async () => { + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'agent.session.display.workdir' })) + + await waitFor(() => { + 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('clears the active agent selection while a resource view is active', () => { + render( + + ) + + expect(screen.getByTestId('resource-entity-rail')).toHaveAttribute('data-selected-id', '') + }) + + 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/components/chat/resourceList/__tests__/ResourceEntityRail.test.tsx b/src/renderer/components/chat/resourceList/__tests__/ResourceEntityRail.test.tsx index e232bc6ff0b..b4b52adf79c 100644 --- a/src/renderer/components/chat/resourceList/__tests__/ResourceEntityRail.test.tsx +++ b/src/renderer/components/chat/resourceList/__tests__/ResourceEntityRail.test.tsx @@ -75,13 +75,18 @@ vi.mock('@renderer/components/VirtualList', () => { renderItem, role, scrollerProps, - scrollElementRef + scrollElementRef, + dragCapabilities }) => { const rows = buildGroupedVirtualRows(groups, Boolean(renderGroupHeader), Boolean(renderGroupFooter)) return (
{ + // The real virtual list exposes an imperative scrollToIndex; stub it so keyboard + // navigation (which scrolls the active item into view) works under this mock. + const listNode = node as (HTMLDivElement & { scrollToIndex?: (index: number) => void }) | null + if (listNode && typeof listNode.scrollToIndex !== 'function') listNode.scrollToIndex = () => {} if (typeof ref === 'function') ref(node) else if (ref) (ref as { current: HTMLDivElement | null }).current = node if (typeof scrollElementRef === 'function') scrollElementRef(node) @@ -93,6 +98,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') { @@ -236,6 +242,119 @@ describe('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('activates entities from the keyboard, toggling the already-selected one', () => { + const onSelect = vi.fn() + const onSelectedClick = vi.fn() + + render( + + ) + + const listbox = screen.getByRole('listbox', { name: 'Assistants' }) + + // Enter on the already-selected entity toggles its pane instead of reselecting. + fireEvent.keyDown(listbox, { key: 'Home' }) + fireEvent.keyDown(listbox, { key: 'Enter' }) + expect(onSelectedClick).toHaveBeenCalledWith(ITEMS[0]) + expect(onSelect).not.toHaveBeenCalled() + + // Space on a different entity selects it, mirroring a mouse click. + fireEvent.keyDown(listbox, { key: 'End' }) + fireEvent.keyDown(listbox, { key: ' ' }) + 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('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() @@ -266,6 +385,26 @@ describe('ResourceEntityRail', () => { 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( { // 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', () => { + 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', () => { diff --git a/src/renderer/components/chat/resourceList/base/ResourceList.tsx b/src/renderer/components/chat/resourceList/base/ResourceList.tsx index 68b14abbc9d..81706122462 100644 --- a/src/renderer/components/chat/resourceList/base/ResourceList.tsx +++ b/src/renderer/components/chat/resourceList/base/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/resourceList/base/ResourceListContext.ts b/src/renderer/components/chat/resourceList/base/ResourceListContext.ts index 2130352db2d..2317c5d7059 100644 --- a/src/renderer/components/chat/resourceList/base/ResourceListContext.ts +++ b/src/renderer/components/chat/resourceList/base/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/resourceList/base/ResourceListGroups.tsx b/src/renderer/components/chat/resourceList/base/ResourceListGroups.tsx index 8f01849ca66..0db2a3e0caf 100644 --- a/src/renderer/components/chat/resourceList/base/ResourceListGroups.tsx +++ b/src/renderer/components/chat/resourceList/base/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 && (
{ + if (!isCollapsible) return + if (clickBehavior === 'select-first-then-toggle' && !selected) { const firstItem = groupItems[0] if (firstItem) { @@ -135,7 +149,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,30 +189,43 @@ export function GroupHeader({ group, className, ref, style, onContextMenu, ...pr {groupHeaderLeadingAction}
)} - + {isCollapsible ? ( + + ) : ( +
+ {groupHeaderIcon && ( + + )} + + {group.label} + +
+ )} {groupHeaderAction && (
= { + 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 = { + 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 runAfterMenuClose = (action: () => void) => { + setOpen(false) + window.setTimeout(action, 0) + } + const manageSkillsMenuIcon = manageSkillsIcon ? ( + {manageSkillsIcon} + ) : undefined + + return ( + + + + + + + + +
+ {t('agent.session.display.title')} +
+ {SESSION_DISPLAY_OPTIONS.map((option) => ( + { + runAfterMenuClose(() => onChange(option)) + }} + /> + ))} + {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={() => { + setOpen(false) + onOpenHistoryRecords() + }} + /> + )} + {hasManagementItems && } + {onManageAgents && ( + } + label={t('agent.manage.title')} + active={manageAgentsActive} + onClick={() => { + setOpen(false) + void onManageAgents() + }} + /> + )} + {onManageSkills && ( + { + setOpen(false) + void onManageSkills() + }} + /> + )} +
+
+
+ ) +} diff --git a/src/renderer/components/chat/resourceList/base/TopicListOptionsMenu.tsx b/src/renderer/components/chat/resourceList/base/TopicListOptionsMenu.tsx new file mode 100644 index 00000000000..9b5227effc2 --- /dev/null +++ b/src/renderer/components/chat/resourceList/base/TopicListOptionsMenu.tsx @@ -0,0 +1,110 @@ +import { MenuDivider, MenuItem, MenuList, Popover, PopoverContent, PopoverTrigger } from '@cherrystudio/ui' +import type { TopicDisplayMode } from '@shared/data/preference/preferenceTypes' +import { Bot, ChevronsDownUp, ChevronsUpDown, 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 = { + manageAssistantsActive?: boolean + mode: TopicDisplayMode + onChange: (mode: TopicDisplayMode) => void + onManageAssistants?: () => void | Promise + onOpenHistoryRecords?: () => void + sectionId?: string +} + +export function TopicListOptionsMenu({ + manageAssistantsActive, + mode, + onChange, + onManageAssistants, + onOpenHistoryRecords, + sectionId +}: TopicListOptionsMenuProps) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const runAfterMenuClose = (action: () => void) => { + setOpen(false) + window.setTimeout(action, 0) + } + + return ( + + + + + + + + +
{t('chat.topics.display.title')}
+ {TOPIC_DISPLAY_OPTIONS.map((option) => ( + { + runAfterMenuClose(() => onChange(option)) + }} + /> + ))} + {sectionId && ( + <> + + } + collapseIcon={} + sectionId={sectionId} + expandLabel={t('chat.topics.group.expand_all')} + collapseLabel={t('chat.topics.group.collapse_all')} + onClick={() => { + setOpen(false) + }} + /> + + )} + {onOpenHistoryRecords && } + {onOpenHistoryRecords && ( + } + label={t('history.records.shortTitle')} + onClick={() => { + setOpen(false) + onOpenHistoryRecords() + }} + /> + )} + {onManageAssistants && } + {onManageAssistants && ( + } + label={t('assistants.presets.manage.title')} + active={manageAssistantsActive} + onClick={() => { + setOpen(false) + void onManageAssistants() + }} + /> + )} +
+
+
+ ) +} diff --git a/src/renderer/components/chat/resourceList/base/__tests__/ResourceList.test.tsx b/src/renderer/components/chat/resourceList/base/__tests__/ResourceList.test.tsx index 3acdde311b1..e9aab313738 100644 --- a/src/renderer/components/chat/resourceList/base/__tests__/ResourceList.test.tsx +++ b/src/renderer/components/chat/resourceList/base/__tests__/ResourceList.test.tsx @@ -1595,11 +1595,13 @@ describe('ResourceList', () => { expect(groupActionButton).toHaveClass('size-6', 'min-h-6', 'min-w-6', 'rounded-md', 'p-0', '[&_svg]:size-3!') expect(groupActionButton).not.toHaveClass('min-h-7.5') expect(groupActionWrapper).toHaveClass( - 'hidden', - 'group-hover/resource-list-group:flex', - 'group-focus-within/resource-list-group:flex', - 'has-data-[state=open]:flex' + 'flex', + 'opacity-0', + 'group-hover/resource-list-group:opacity-100', + 'group-focus-within/resource-list-group:opacity-100', + 'has-data-[state=open]:opacity-100' ) + expect(groupActionWrapper).not.toHaveClass('hidden') }) it('opens group header context menus from the group header trigger', () => { diff --git a/src/renderer/components/chat/resourceList/base/defaultCollapsedGroups.ts b/src/renderer/components/chat/resourceList/base/defaultCollapsedGroups.ts new file mode 100644 index 00000000000..f56c82afe25 --- /dev/null +++ b/src/renderer/components/chat/resourceList/base/defaultCollapsedGroups.ts @@ -0,0 +1,21 @@ +import type { ResourceListGroup } from './ResourceListContext' + +export function resolveDefaultCollapsedGroupIds({ + collapsedIds, + groupBy, + items +}: { + collapsedIds: readonly string[] | null + groupBy: (item: T) => ResourceListGroup | null + items: readonly T[] +}): readonly string[] { + if (collapsedIds !== null) return collapsedIds + + const groupIds = new Set() + for (const item of items) { + const group = groupBy(item) + if (group?.label) groupIds.add(group.id) + } + + return [...groupIds] +} diff --git a/src/renderer/components/chat/resourceList/base/index.ts b/src/renderer/components/chat/resourceList/base/index.ts index ec8d5ab0d04..4d168b89a4e 100644 --- a/src/renderer/components/chat/resourceList/base/index.ts +++ b/src/renderer/components/chat/resourceList/base/index.ts @@ -2,6 +2,20 @@ export { ConversationResourceMenu, type ConversationResourceMenuItem } from './ConversationResourceMenu' +export { resolveDefaultCollapsedGroupIds } from './defaultCollapsedGroups' +export { + buildResolvedResourceEntityMenuAction, + buildResourceEntityIconTypeActionDescriptor, + buildResourceEntityMenuActionDescriptor +} from './resourceEntityActions' +export { + buildIconTypeActionDescriptors, + buildResolvedIconTypeActions, + buildResolvedIconTypeMenuAction, + renderAgentEntityIcon, + renderAssistantEntityIcon, + RESOURCE_ICON_TYPE_OPTIONS +} from './resourceEntityIcon' export type { ResourceListActionMap, ResourceListContextValue, @@ -55,5 +69,7 @@ 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' diff --git a/src/renderer/components/chat/resourceList/base/resourceEntityActions.tsx b/src/renderer/components/chat/resourceList/base/resourceEntityActions.tsx new file mode 100644 index 00000000000..ddf95d8c40f --- /dev/null +++ b/src/renderer/components/chat/resourceList/base/resourceEntityActions.tsx @@ -0,0 +1,63 @@ +import type { + ActionAvailability, + ActionDescriptor, + ActionNode, + ResolvedAction +} from '@renderer/components/chat/actions/actionTypes' + +const DEFAULT_RESOLVED_ACTION_AVAILABILITY: ActionAvailability = { + visible: true, + enabled: true +} + +type ResourceEntityMenuActionDescriptorParams = Omit, 'surface'> + +export function buildResourceEntityMenuActionDescriptor({ + ...descriptor +}: ResourceEntityMenuActionDescriptorParams): ActionDescriptor { + return { + ...descriptor, + surface: 'menu' + } +} + +type ResolvedResourceEntityMenuActionParams = { + id: string + label: ResolvedAction['label'] + availability?: ActionAvailability + children?: ResolvedAction[] + commandId?: string + danger?: boolean + group?: string + icon?: ResolvedAction['icon'] + order?: number +} + +export function buildResolvedResourceEntityMenuAction({ + availability = DEFAULT_RESOLVED_ACTION_AVAILABILITY, + children = [], + danger = false, + ...action +}: ResolvedResourceEntityMenuActionParams): ResolvedAction { + return { + ...action, + danger, + availability, + children + } +} + +type ResourceEntityIconTypeActionDescriptorParams = { + id: string + commandId?: string + label: ActionNode + icon: ActionNode + order: number + children: readonly ActionDescriptor[] +} + +export function buildResourceEntityIconTypeActionDescriptor( + params: ResourceEntityIconTypeActionDescriptorParams +): ActionDescriptor { + return buildResourceEntityMenuActionDescriptor(params) +} diff --git a/src/renderer/components/chat/resourceList/base/resourceEntityIcon.tsx b/src/renderer/components/chat/resourceList/base/resourceEntityIcon.tsx new file mode 100644 index 00000000000..718d4332a18 --- /dev/null +++ b/src/renderer/components/chat/resourceList/base/resourceEntityIcon.tsx @@ -0,0 +1,122 @@ +import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' +import type { ActionDescriptor, ResolvedAction } from '@renderer/components/chat/actions/actionTypes' +import EmojiIcon from '@renderer/components/EmojiIcon' +import { getAgentAvatarFromConfiguration } from '@renderer/utils/agent' +import type { AgentConfiguration } from '@shared/data/api/schemas/agents' +import type { AssistantIconType } from '@shared/data/preference/preferenceTypes' +import { DEFAULT_ASSISTANT_EMOJI } from '@shared/data/presets/defaultAssistant' +import { isUniqueModelId, parseUniqueModelId } from '@shared/data/types/model' +import type { TFunction } from 'i18next' +import { Bot, Check } from 'lucide-react' +import type { ReactNode } from 'react' + +import { buildResolvedResourceEntityMenuAction } from './resourceEntityActions' + +export const RESOURCE_ICON_TYPE_OPTIONS: readonly AssistantIconType[] = ['emoji', 'model', 'none'] + +const RESOURCE_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 + } +} + +function renderFallbackAssistantIcon(emoji?: string | null) { + return emoji ? ( + + ) : ( + + + + ) +} + +export function renderAssistantEntityIcon( + iconType: AssistantIconType, + assistant: { emoji?: string | null; modelId?: string | null; modelName?: string | null }, + fallbackModelId?: string | null +) { + if (iconType === 'none') return undefined + + const modelAvatarModel = buildModelAvatarModel(assistant.modelId ?? fallbackModelId, assistant.modelName) + if (iconType === 'model' && modelAvatarModel) return + + return renderFallbackAssistantIcon(assistant.emoji) +} + +export function renderAgentEntityIcon( + iconType: AssistantIconType, + agent: { configuration?: AgentConfiguration; model?: string | null; modelName?: string | null } | undefined, + fallbackModelId?: string | null +) { + if (iconType === 'none') return undefined + + const modelAvatarModel = buildModelAvatarModel(agent?.model ?? fallbackModelId, agent?.modelName) + if (iconType === 'model' && modelAvatarModel) return + + return ( + + ) +} + +export function buildResolvedIconTypeActions( + parentActionId: string, + currentIconType: AssistantIconType, + t: TFunction +): ResolvedAction[] { + return RESOURCE_ICON_TYPE_OPTIONS.map((type) => ({ + id: `${parentActionId}.${type}`, + label: t(RESOURCE_ICON_TYPE_LABEL_KEYS[type]), + icon: currentIconType === type ? : , + order: 0, + danger: false, + availability: { visible: true, enabled: true }, + children: [] + })) +} + +export function buildResolvedIconTypeMenuAction( + parentActionId: string, + label: ReactNode, + icon: ReactNode, + order: number, + currentIconType: AssistantIconType, + t: TFunction +): ResolvedAction { + return buildResolvedResourceEntityMenuAction({ + id: parentActionId, + label, + icon, + order, + children: buildResolvedIconTypeActions(parentActionId, currentIconType, t) + }) +} + +export function buildIconTypeActionDescriptors( + commandPrefix: string +): ActionDescriptor[] { + return RESOURCE_ICON_TYPE_OPTIONS.map((type) => ({ + id: `${commandPrefix}.${type}`, + commandId: `${commandPrefix}.${type}`, + label: ({ t }) => t(RESOURCE_ICON_TYPE_LABEL_KEYS[type]), + icon: ({ assistantIconType }) => + assistantIconType === type ? : , + order: 0, + surface: 'menu' + })) +} diff --git a/src/renderer/components/chat/resourceList/useResourceEntityRail.ts b/src/renderer/components/chat/resourceList/useResourceEntityRail.ts index 5dc37464976..eee8bccbe34 100644 --- a/src/renderer/components/chat/resourceList/useResourceEntityRail.ts +++ b/src/renderer/components/chat/resourceList/useResourceEntityRail.ts @@ -119,6 +119,7 @@ export function useResourceEntityRail ({ 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/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/chat/shell/__tests__/RightPaneHost.test.tsx b/src/renderer/components/chat/shell/__tests__/RightPaneHost.test.tsx index 141e8ec488d..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: 460 } + 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 a503a73a4af..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 = 360 -export const ARTIFACT_RIGHT_PANE_DEFAULT_WIDTH = 460 +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/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/components/composer/variants/AgentComposer.tsx b/src/renderer/components/composer/variants/AgentComposer.tsx index fae5e2b3eeb..a2f0598a216 100644 --- a/src/renderer/components/composer/variants/AgentComposer.tsx +++ b/src/renderer/components/composer/variants/AgentComposer.tsx @@ -172,17 +172,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, { @@ -671,7 +673,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) @@ -835,7 +838,7 @@ const AgentComposerInner = ({ const label = t('agent.session.new') - if (sessionLayout === 'classic') { + if (isClassicSessionLayout) { if (!onCreateEmptySession) return [] return [ @@ -864,7 +867,7 @@ const AgentComposerInner = ({ } } ] - }, [agentBase, handleCreateEmptySession, onCreateEmptySession, onNewSessionDraft, sessionLayout, t]) + }, [agentBase, handleCreateEmptySession, isClassicSessionLayout, onCreateEmptySession, onNewSessionDraft, t]) const toolsSession = useMemo(() => { if (!sessionData) return undefined @@ -1070,7 +1073,7 @@ const AgentComposerInner = ({ selectModelLabel: t('button.select_model'), agentChanging, shouldAutoSelectCreatedAgent: Boolean(onAgentChange), - showAgentTrigger: sessionLayout !== 'classic', + showAgentTrigger: !isClassicSessionLayout, canChangeModel, onModelSelect: handleModelSelect, modelFilter: agentModelFilter, @@ -1177,7 +1180,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') @@ -1211,7 +1215,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 9b10aefb25e..3c4f59c44b8 100644 --- a/src/renderer/components/composer/variants/ChatComposer.tsx +++ b/src/renderer/components/composer/variants/ChatComposer.tsx @@ -467,8 +467,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() @@ -695,7 +696,7 @@ const ChatComposerInner = ({ }, [onCreateEmptyTopic, selectedAssistantId]) const handleNewTopicShortcut = useCallback(() => { - if (topicLayout === 'classic' && onCreateEmptyTopic) { + if (isClassicTopicLayout && onCreateEmptyTopic) { if (isAssistantLoading || hasMissingPersistedAssistant) return handleCreateEmptyTopic() return @@ -704,7 +705,7 @@ const ChatComposerInner = ({ addNewTopic() }, [ addNewTopic, - topicLayout, + isClassicTopicLayout, handleCreateEmptyTopic, hasMissingPersistedAssistant, isAssistantLoading, @@ -714,7 +715,7 @@ const ChatComposerInner = ({ const rootPanelLeadingItems = useMemo(() => { const label = t('chat.conversation.new') - if (topicLayout === 'classic') { + if (isClassicTopicLayout) { if (!onCreateEmptyTopic) return [] const disabled = isAssistantLoading || hasMissingPersistedAssistant @@ -753,7 +754,7 @@ const ChatComposerInner = ({ onCreateEmptyTopic, onNewTopic, t, - topicLayout + isClassicTopicLayout ]) const handleSurfaceActionsChange = useCallback( @@ -1022,7 +1023,7 @@ const ChatComposerInner = ({ useMentionedModelSelector, shouldAutoSelectCreatedAssistant: Boolean(onDraftAssistantChange), selectModelLabel: runtimeModelPending ? t('common.loading') : t('button.select_model'), - showAssistantTrigger: topicLayout !== 'classic', + showAssistantTrigger: !isClassicTopicLayout || !selectedAssistantId, 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 a9d94b7c879..86e4e39e468 100644 --- a/src/renderer/components/composer/variants/__tests__/AgentComposer.test.tsx +++ b/src/renderer/components/composer/variants/__tests__/AgentComposer.test.tsx @@ -400,7 +400,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 02a2c7c7cae..1be5300b6e3 100644 --- a/src/renderer/components/composer/variants/__tests__/ChatComposer.test.tsx +++ b/src/renderer/components/composer/variants/__tests__/ChatComposer.test.tsx @@ -383,7 +383,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]] } @@ -970,6 +970,17 @@ describe('ChatComposer', () => { expect(mocks.updateTopic).not.toHaveBeenCalled() }) + it('keeps the assistant selector available in classic layout when no assistant is selected', () => { + mocks.topicLayout = 'classic' + mocks.assistant = undefined + + render() + + expect(screen.getByTestId('assistant-selector')).toHaveAttribute('data-value', '') + expect(screen.getByTestId('assistant-selector')).toHaveAttribute('data-auto-select-on-create', 'true') + expect(screen.getByTestId('composer-below-controls')).toHaveTextContent('button.select_assistant') + }) + it('keeps the assistant switcher in the toolbar in the modern layout', () => { mocks.topicLayout = 'modern' diff --git a/src/renderer/components/resourceCatalog/dialogs/components/EditDialogShared.tsx b/src/renderer/components/resourceCatalog/dialogs/components/EditDialogShared.tsx index 81c1a5b2004..49d8eb30e85 100644 --- a/src/renderer/components/resourceCatalog/dialogs/components/EditDialogShared.tsx +++ b/src/renderer/components/resourceCatalog/dialogs/components/EditDialogShared.tsx @@ -28,7 +28,7 @@ import { useProviderDisplayName } from '@renderer/hooks/useProvider' import { isUniqueModelId, type Model, parseUniqueModelId, type UniqueModelId } from '@shared/data/types/model' import { ChevronDown, Database, HelpCircle, Trash2, X } from 'lucide-react' import { type ComponentProps, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' -import type { FieldValues, Path, UseFormReturn } from 'react-hook-form' +import { type FieldValues, type Path, type UseFormReturn, useWatch } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { AddCatalogPopover, type CatalogItem } from './CatalogPicker' @@ -50,12 +50,18 @@ export type EditDialogTab = { children?: EditDialogTab[] } +export type EditDialogGroupExpansion = 'collapsed' | 'all' +export type EditDialogGroupPresentation = 'grouped' | 'inline' + function resolveTabValue(tabs: EditDialogTab[], value: string) { const matched = tabs.find((tab) => tab.id === value) return matched?.children?.[0]?.id ?? value } -function getDefaultExpandedGroupIds() { +function getDefaultExpandedGroupIds(tabs: EditDialogTab[], groupExpansion: EditDialogGroupExpansion) { + if (groupExpansion === 'all') { + return new Set(tabs.filter((tab) => tab.children?.length).map((tab) => tab.id)) + } return new Set() } @@ -185,7 +191,8 @@ export function KnowledgeBaseField({ const { data, isLoading } = useQuery('/knowledge-bases', { query: { limit: 100 } }) const bases = useMemo(() => data?.items ?? [], [data]) const fieldName = 'knowledgeBaseIds' as Path - const value = (form.watch(fieldName) ?? []) as string[] + const watchedValue = useWatch({ control: form.control, name: fieldName }) + const value = useMemo(() => (watchedValue ?? []) as string[], [watchedValue]) const { catalog, linkedItems } = useMemo(() => { const byId = new Map(bases.map((base) => [base.id, base])) @@ -288,12 +295,16 @@ export function EditDialogShell({ rootError, setDialogContentElement, tabs, - title + title, + groupExpansion = 'collapsed', + groupPresentation = 'grouped' }: { activeTab: string canSave: boolean children: ReactNode form: UseFormReturn + groupExpansion?: EditDialogGroupExpansion + groupPresentation?: EditDialogGroupPresentation isSubmitting: boolean onActiveTabChange: (tab: string) => void onOpenChange: (open: boolean) => void @@ -306,7 +317,9 @@ export function EditDialogShell({ }) { const { t } = useTranslation() const scrollContainerRef = useRef(null) - const [expandedGroupIds, setExpandedGroupIds] = useState>(() => getDefaultExpandedGroupIds()) + const [expandedGroupIds, setExpandedGroupIds] = useState>(() => + getDefaultExpandedGroupIds(tabs, groupExpansion) + ) useEffect(() => { if (scrollContainerRef.current) { @@ -315,8 +328,8 @@ export function EditDialogShell({ }, [activeTab]) useEffect(() => { - setExpandedGroupIds(open ? getDefaultExpandedGroupIds() : new Set()) - }, [open, tabs]) + setExpandedGroupIds(open ? getDefaultExpandedGroupIds(tabs, groupExpansion) : new Set()) + }, [groupExpansion, open, tabs]) useEffect(() => { const activeGroup = tabs.find((tab) => tab.children?.some((child) => child.id === activeTab)) @@ -372,6 +385,14 @@ export function EditDialogShell({ {tabs.map((tab) => { const hasChildren = Boolean(tab.children?.length) + if (hasChildren && groupPresentation === 'inline') { + return tab.children?.map((child) => ( + + {child.label} + + )) + } + const groupExpanded = expandedGroupIds.has(tab.id) return ( diff --git a/src/renderer/components/resourceCatalog/dialogs/edit/AgentEditDialog.tsx b/src/renderer/components/resourceCatalog/dialogs/edit/AgentEditDialog.tsx index dfe74f6cce8..db1ddced4d3 100644 --- a/src/renderer/components/resourceCatalog/dialogs/edit/AgentEditDialog.tsx +++ b/src/renderer/components/resourceCatalog/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')}>
- + {/* 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} @@ -155,12 +155,12 @@ export function ConversationPickerDialog({ {labels.loadingText}
) : visibleItems.length > 0 ? ( - + {visibleItems.map((item) => ( diff --git a/src/renderer/components/resourceCatalog/selectors/__tests__/ConversationPickerDialog.test.tsx b/src/renderer/components/resourceCatalog/selectors/__tests__/ConversationPickerDialog.test.tsx index b3321ce97cb..8a69dd09ab4 100644 --- a/src/renderer/components/resourceCatalog/selectors/__tests__/ConversationPickerDialog.test.tsx +++ b/src/renderer/components/resourceCatalog/selectors/__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() diff --git a/src/renderer/hooks/useClassicLayoutRightPaneOpen.ts b/src/renderer/hooks/useClassicLayoutRightPaneOpen.ts index e444088c080..8fcb0a3f04e 100644 --- a/src/renderer/hooks/useClassicLayoutRightPaneOpen.ts +++ b/src/renderer/hooks/useClassicLayoutRightPaneOpen.ts @@ -6,6 +6,8 @@ const RIGHT_PANE_OPEN_CACHE_KEY = { agent: 'ui.agent.right_pane_open' } as const +type ClassicLayoutPaneOpenSetter = (open: boolean, options?: { force?: boolean }) => void + /** * Classic-layout right-pane open state, cached per surface so the assistant (`'chat'`) and agent * surfaces never bleed into each other (mirrors the `ui.chat.*` vs `ui.agent.*` split used elsewhere). @@ -16,12 +18,12 @@ const RIGHT_PANE_OPEN_CACHE_KEY = { export function useClassicLayoutRightPaneOpen( surface: 'chat' | 'agent', isClassicLayout: boolean -): readonly [boolean, (open: boolean) => void] { +): readonly [boolean, ClassicLayoutPaneOpenSetter] { const [stored, setStored] = usePersistCache(RIGHT_PANE_OPEN_CACHE_KEY[surface]) const paneOpen = isClassicLayout && stored - const setPaneOpen = useCallback( - (open: boolean) => { - if (isClassicLayout) setStored(open) + const setPaneOpen = useCallback( + (open, options) => { + if (isClassicLayout || options?.force) setStored(open) }, [isClassicLayout, setStored] ) diff --git a/src/renderer/i18n/label.ts b/src/renderer/i18n/label.ts index a168338f418..c1d065fe24d 100644 --- a/src/renderer/i18n/label.ts +++ b/src/renderer/i18n/label.ts @@ -185,7 +185,7 @@ export const getThemeModeLabelKey = (key: string): string => { } const sidebarIconKeyMap = { - assistants: 'agent.session.group.conversation', + assistants: 'title.chat', agents: 'title.work', paintings: 'title.paintings', translate: 'translate.title', diff --git a/src/renderer/i18n/locales/en-us.json b/src/renderer/i18n/locales/en-us.json index d410c43545d..314f5b7d302 100644 --- a/src/renderer/i18n/locales/en-us.json +++ b/src/renderer/i18n/locales/en-us.json @@ -339,6 +339,9 @@ "home": { "welcome_title": "What shall we talk about today?" }, + "icon": { + "type": "Agent Icon" + }, "input": { "placeholder": "Type a message. Press {{key}} to send. Type / to search paths or commands.", "soul_placeholder": "Help me connect to Telegram" @@ -348,6 +351,9 @@ "failed": "Failed to list agents." } }, + "manage": { + "title": "Manage Agents" + }, "pin": { "title": "Pin Agent" }, @@ -784,6 +790,11 @@ } }, "sidebar_title": "Agents", + "skill": { + "manage": { + "title": "Manage skills" + } + }, "todo": { "mock": { "actions": { @@ -1085,8 +1096,9 @@ "assistants": { "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", + "content": "Clearing topics will delete all topics under this assistant. Are you sure you want to continue?", + "menu_title": "Clear Topics", + "success_title": "Cleared {{count}} topics", "title": "Clear conversations" }, "copy": { @@ -3044,7 +3056,7 @@ "skills_coming_soon": "Skill bindings coming soon", "skills_require_save": "Save before enabling skills", "tab": { - "mcp": "MCP Server", + "mcp": "MCP", "skills": "Skills", "tools": "Built-in tools" }, @@ -5313,6 +5325,13 @@ "auto_switch_to_topics": "Auto switch to conversation", "title": "Advanced Settings" }, + "agent": { + "position": { + "label": "Session position", + "left": "Left", + "right": "Right" + } + }, "appearance": { "title": "Appearance" }, @@ -7575,6 +7594,7 @@ }, "title": { "apps": "Apps", + "chat": "Chat", "code": "Code", "files": "Files", "home": "Home", diff --git a/src/renderer/i18n/locales/zh-cn.json b/src/renderer/i18n/locales/zh-cn.json index d9f91fb24ef..a7c1bd82fc0 100644 --- a/src/renderer/i18n/locales/zh-cn.json +++ b/src/renderer/i18n/locales/zh-cn.json @@ -339,6 +339,9 @@ "home": { "welcome_title": "今天想聊点什么?" }, + "icon": { + "type": "智能体图标" + }, "input": { "placeholder": "输入消息,按 {{key}} 发送,输入 / 搜索路径或命令", "soul_placeholder": "帮我连接微信" @@ -348,6 +351,9 @@ "failed": "获取智能体列表失败" } }, + "manage": { + "title": "管理智能体" + }, "pin": { "title": "置顶智能体" }, @@ -784,6 +790,11 @@ } }, "sidebar_title": "智能体", + "skill": { + "manage": { + "title": "管理技能" + } + }, "todo": { "mock": { "actions": { @@ -1085,8 +1096,9 @@ "assistants": { "abbr": "助手", "clear": { - "content": "清空对话会删除助手下所有对话和文件,确定要继续吗?", - "menu_title": "删除助手下所有对话", + "content": "清空话题会删除助手下所有话题,确定要继续吗?", + "menu_title": "清空话题", + "success_title": "已清空 {{count}} 个话题", "title": "清空对话" }, "copy": { @@ -3044,7 +3056,7 @@ "skills_coming_soon": "Skill 绑定即将支持", "skills_require_save": "保存后才能启用技能", "tab": { - "mcp": "MCP Server", + "mcp": "MCP", "skills": "Skills", "tools": "内置工具" }, @@ -5313,6 +5325,13 @@ "auto_switch_to_topics": "自动切换到对话", "title": "高级设置" }, + "agent": { + "position": { + "label": "会话位置", + "left": "左侧", + "right": "右侧" + } + }, "appearance": { "title": "外观" }, @@ -7575,6 +7594,7 @@ }, "title": { "apps": "小程序", + "chat": "对话", "code": "Code", "files": "文件", "home": "首页", diff --git a/src/renderer/i18n/locales/zh-tw.json b/src/renderer/i18n/locales/zh-tw.json index f27e2bf135c..ba5e531e124 100644 --- a/src/renderer/i18n/locales/zh-tw.json +++ b/src/renderer/i18n/locales/zh-tw.json @@ -339,6 +339,9 @@ "home": { "welcome_title": "今天我們該談些什麼?" }, + "icon": { + "type": "智能體圖示" + }, "input": { "placeholder": "輸入訊息,按 {{key}} 傳送,輸入 / 搜尋路徑或指令", "soul_placeholder": "幫我連接微信" @@ -348,6 +351,9 @@ "failed": "無法列出 Agent。" } }, + "manage": { + "title": "管理智能體" + }, "pin": { "title": "圖釘代理" }, @@ -784,6 +790,11 @@ } }, "sidebar_title": "Agents", + "skill": { + "manage": { + "title": "管理技能" + } + }, "todo": { "mock": { "actions": { @@ -1085,8 +1096,9 @@ "assistants": { "abbr": "助手", "clear": { - "content": "清空對話會刪除助手下所有對話和檔案,確定要繼續嗎?", - "menu_title": "刪除助手下所有對話", + "content": "清空話題會刪除助手下所有話題,確定要繼續嗎?", + "menu_title": "清空話題", + "success_title": "已清空 {{count}} 個話題", "title": "清空對話" }, "copy": { @@ -3044,7 +3056,7 @@ "skills_coming_soon": "Skill 綁定即將支援", "skills_require_save": "儲存後才能啟用技能", "tab": { - "mcp": "MCP Server", + "mcp": "MCP", "skills": "Skills", "tools": "內建工具" }, @@ -5313,6 +5325,13 @@ "auto_switch_to_topics": "自動切換到對話", "title": "進階設定" }, + "agent": { + "position": { + "label": "會話位置", + "left": "左側", + "right": "右側" + } + }, "appearance": { "title": "外觀" }, @@ -7575,6 +7594,7 @@ }, "title": { "apps": "小程式", + "chat": "對話", "code": "程式碼", "files": "檔案", "home": "首頁", diff --git a/src/renderer/pages/agents/AgentPage.tsx b/src/renderer/pages/agents/AgentPage.tsx index 737c353ac2e..b6a83c3c001 100644 --- a/src/renderer/pages/agents/AgentPage.tsx +++ b/src/renderer/pages/agents/AgentPage.tsx @@ -6,7 +6,6 @@ import { AgentResourceList } from '@renderer/components/chat/resourceList/AgentR import type { ResourceListRevealRequest } from '@renderer/components/chat/resourceList/base' import ConversationPageShell from '@renderer/components/chat/shell/ConversationPageShell' import { ConversationSidebarToggleButton } from '@renderer/components/chat/shell/ConversationSidebarToggleButton' -import type { ChatPanePosition } from '@renderer/components/chat/shell/paneLayout' import { createRecentSessionEntryFromSession, upsertGlobalSearchRecentEntry @@ -36,6 +35,7 @@ import { cn } from '@renderer/utils/style' import { getTabInstanceKey } from '@renderer/utils/tabInstanceMetadata' import type { AgentSessionEntity } from '@shared/data/api/schemas/agentSessions' import { AGENT_WORKSPACE_TYPE, type AgentSessionWorkspaceSource } from '@shared/data/api/schemas/agentWorkspaces' +import type { TopicTabPosition } from '@shared/data/preference/preferenceTypes' import { buildFirstUserMessageTitle } from '@shared/utils/conversationTitle' import { MIN_WINDOW_HEIGHT, SECOND_MIN_WINDOW_WIDTH } from '@shared/utils/window' import { useSearch } from '@tanstack/react-router' @@ -86,8 +86,9 @@ function findReusableEmptySession { const [showSidebar, setShowSidebar] = usePreference('topic.tab.show') - const [sessionLayout] = usePreference('agent.layout') - const isClassicSessionLayout = sessionLayout === 'classic' + const [sessionDisplayMode, setSessionDisplayMode] = usePreference('agent.session.display_mode') + const [panePosition, setPanePosition] = usePreference('agent.session.position') + 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 { @@ -119,6 +120,7 @@ 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 isCreatingClassicEmptySessionRef = useRef(false) useEffect(() => { pendingSelectedSessionRef.current = null @@ -135,6 +137,7 @@ const AgentPage = () => { const [lastUsedAgentId, setLastUsedAgentId] = usePersistCache('ui.agent.last_used_agent_id') const [lastUsedWorkspaceId, setLastUsedWorkspaceId] = usePersistCache('ui.agent.last_used_workspace_id') const [, setRecentItems] = usePersistCache('ui.global_search.recent_items') + const [, setSessionExpansionAgent] = usePersistCache('ui.agent.session.expansion.agent') const lastRecordedRecentSessionRef = useRef(undefined) const [sessionRevealRequest, setSessionRevealRequest] = useState() const [pendingLocateMessageId, setPendingLocateMessageId] = useState() @@ -455,6 +458,8 @@ const AgentPage = () => { const handleAgentConversationSelect = useCallback( async (agentId: string) => { + if (isCreatingClassicEmptySessionRef.current) return + isCreatingClassicEmptySessionRef.current = true // Close the picker first so the session/state churn below doesn't refresh the dialog while it's // still visible (which reads as a black/white flash + the dialog reopening). setAgentPickerOpen(false) @@ -502,6 +507,8 @@ const AgentPage = () => { } catch (err) { logger.error('Failed to create agent session from classic-layout picker', err as Error, { agentId }) window.toast.error(formatErrorMessageWithPrefix(err, t('agent.session.create.error.failed'))) + } finally { + isCreatingClassicEmptySessionRef.current = false } }, [ @@ -870,30 +877,32 @@ const AgentPage = () => { setPendingLocateMessageId(undefined) }, []) - const panePosition: ChatPanePosition = 'left' // Classic layout = entity rail + right session panel; modern layout = the single sidebar (AgentSidePanel). const activeResourceAgentId = visibleSession?.agentId ?? visibleDraftSession?.agentId ?? null const sessionResourcePaneCount: ResourcePaneCountButtonProps | undefined = - isClassicSessionLayout && activeResourceAgentId + isClassicSessionLayout && panePosition === 'right' && activeResourceAgentId ? { label: t('agent.session.list.title'), count: classicLayoutSessions.filter((session) => session.agentId === activeResourceAgentId).length } : undefined const createAndActivateEmptySession = useCallback(async () => { - closeResourceView() - const agentId = activeResourceAgentId - if (!agentId) return - - const workspaceSource: AgentSessionWorkspaceSource = visibleDraftSession - ? visibleDraftSession.workspaceSource - : visibleSession?.workspace?.type === AGENT_WORKSPACE_TYPE.SYSTEM - ? { type: AGENT_WORKSPACE_TYPE.SYSTEM } - : visibleSession?.workspaceId - ? { type: AGENT_WORKSPACE_TYPE.USER, workspaceId: visibleSession.workspaceId } - : { type: AGENT_WORKSPACE_TYPE.SYSTEM } + if (isCreatingClassicEmptySessionRef.current) return + isCreatingClassicEmptySessionRef.current = true try { + closeResourceView() + const agentId = activeResourceAgentId + if (!agentId) return + + const workspaceSource: AgentSessionWorkspaceSource = visibleDraftSession + ? visibleDraftSession.workspaceSource + : visibleSession?.workspace?.type === AGENT_WORKSPACE_TYPE.SYSTEM + ? { type: AGENT_WORKSPACE_TYPE.SYSTEM } + : visibleSession?.workspaceId + ? { type: AGENT_WORKSPACE_TYPE.USER, workspaceId: visibleSession.workspaceId } + : { type: AGENT_WORKSPACE_TYPE.SYSTEM } + // Composer "new session" stays in the current workspace, so only reuse a placeholder that // matches it. See findReusableEmptySession. const reusableSession = findReusableEmptySession( @@ -922,8 +931,12 @@ const AgentPage = () => { }) } } catch (err) { - logger.error('Failed to create empty agent session from classic-layout composer', err as Error, { agentId }) + logger.error('Failed to create empty agent session from classic-layout composer', err as Error, { + agentId: activeResourceAgentId + }) window.toast.error(formatErrorMessageWithPrefix(err, t('agent.session.create.error.failed'))) + } finally { + isCreatingClassicEmptySessionRef.current = false } }, [ activeResourceAgentId, @@ -938,50 +951,96 @@ const AgentPage = () => { visibleSession?.workspace?.type, visibleSession?.workspaceId ]) - const pane = isClassicSessionLayout ? ( - { - setAgentPickerOpen(true) - }} - onOpenHistoryRecords={openHistoryRecords} - onSelectSession={handleResourceSessionSelect} - onStartDraftAgent={(agentId) => startDraftSession({ agentId })} - onStartMissingAgentDraft={startMissingAgentDraft} - resourceMenuItems={resourceMenuItems} - onActiveAgentDeleted={handleActiveAgentDeleted} - /> - ) : ( - + const setSessionListPosition = useCallback( + async (position: TopicTabPosition) => { + await setSessionDisplayMode('agent') + if (position === 'left') { + const activeAgentId = visibleSession?.agentId ?? visibleDraftSession?.agentId + const collapsedAgentGroupIds = Array.from( + new Set( + classicLayoutSessions + .map((session) => session.agentId) + .filter((agentId): agentId is string => !!agentId && agentId !== activeAgentId) + .map((agentId) => `session:agent:${agentId}`) + ) + ) + setSessionExpansionAgent(collapsedAgentGroupIds) + } + await setPanePosition(position) + setSessionPaneOpen(position === 'right', { force: true }) + setResourceListOpen(true) + }, + [ + classicLayoutSessions, + setPanePosition, + setResourceListOpen, + setSessionDisplayMode, + setSessionExpansionAgent, + setSessionPaneOpen, + visibleDraftSession?.agentId, + visibleSession?.agentId + ] ) + const sessionListPosition: TopicTabPosition = isClassicSessionLayout && panePosition === 'right' ? 'right' : 'left' + const shellPanePosition: TopicTabPosition = 'left' + const pane = + isClassicSessionLayout && sessionListPosition === 'right' ? ( + { + setAgentPickerOpen(true) + }} + onOpenHistoryRecords={openHistoryRecords} + onSelectSession={handleResourceSessionSelect} + onSelectedAgentClick={() => { + closeResourceView() + setSessionPaneOpen(!sessionPaneOpen) + }} + onStartDraftAgent={(agentId) => startDraftSession({ agentId })} + onStartMissingAgentDraft={startMissingAgentDraft} + resourceMenuItems={resourceMenuItems} + onActiveAgentDeleted={handleActiveAgentDeleted} + /> + ) : ( + { + setAgentPickerOpen(true) + }} + revealRequest={sessionRevealRequest} + onOpenHistoryRecords={openHistoryRecords} + onStartDraftSession={startDraftSession} + onStartMissingAgentDraft={isMessageOnlyView ? undefined : startMissingAgentDraft} + onSetPanePosition={setSessionListPosition} + panePosition="left" + resourceMenuItems={resourceMenuItems} + setActiveSessionId={setActiveSessionAndDiscardDraft} + /> + ) // In classic layout the session list moves into the chat's right pane as a tab; AgentChat keeps the // pane provider per-branch (its Shell meta is bound to per-session runtime, unlike Home), so the // config is threaded into each branch rather than lifted to this page. - const resourcePane: ResourcePaneConfig | null = isClassicSessionLayout - ? { - label: t('agent.session.list.title'), - node: ( - - ) - } - : null + const resourcePane: ResourcePaneConfig | null = + isClassicSessionLayout && sessionListPosition === 'right' + ? { + label: t('agent.session.list.title'), + node: ( + + ) + } + : null const resourceCenter = useMemo( () => activeResourceViewKind @@ -1014,7 +1073,7 @@ const AgentPage = () => { center={resourceCenter} pane={pane} paneOpen={effectiveShowSidebar} - panePosition={panePosition} + panePosition={shellPanePosition} onPaneCollapse={() => setResourceListOpen(false)} /> ) : ( @@ -1026,7 +1085,7 @@ const AgentPage = () => { lockedSession={isMessageOnlyView ? (routeSession ?? null) : undefined} lockedSessionLoading={isMessageOnlyView && isRouteSessionLoading} paneOpen={effectiveShowSidebar} - panePosition={panePosition} + panePosition={shellPanePosition} onPaneCollapse={() => setResourceListOpen(false)} showResourceListControls={!isMessageOnlyView && !isWindowFrame} sidebarOpen={effectiveShowSidebar} diff --git a/src/renderer/pages/agents/AgentSidePanel.tsx b/src/renderer/pages/agents/AgentSidePanel.tsx index 77d00b6139a..d8ad101f93c 100644 --- a/src/renderer/pages/agents/AgentSidePanel.tsx +++ b/src/renderer/pages/agents/AgentSidePanel.tsx @@ -3,16 +3,20 @@ import type { ResourceListRevealRequest } from '@renderer/components/chat/resourceList/base' import type { AgentSessionEntity } from '@shared/data/api/schemas/agentSessions' +import type { TopicTabPosition } from '@shared/data/preference/preferenceTypes' import Sessions from './components/Sessions' import type { DraftAgentSessionDefaults } from './types' interface AgentSidePanelProps { activeSessionId: string | null + onActiveAgentDeleted?: (agentId: string) => void | Promise + onAddAgent?: () => void | Promise onOpenHistoryRecords?: () => void - onSelectItem?: () => void + onSetPanePosition?: (position: TopicTabPosition) => void | Promise onStartDraftSession?: (defaults: DraftAgentSessionDefaults) => void | Promise onStartMissingAgentDraft?: () => void | Promise + panePosition?: TopicTabPosition revealRequest?: ResourceListRevealRequest resourceMenuItems?: readonly ConversationResourceMenuItem[] setActiveSessionId: (id: string | null, session?: AgentSessionEntity | null) => void @@ -20,10 +24,13 @@ interface AgentSidePanelProps { const AgentSidePanel = ({ activeSessionId, + onActiveAgentDeleted, + onAddAgent, onOpenHistoryRecords, - onSelectItem, + onSetPanePosition, onStartDraftSession, onStartMissingAgentDraft, + panePosition, revealRequest, resourceMenuItems, setActiveSessionId @@ -39,8 +46,11 @@ const AgentSidePanel = ({ ({ 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: 280, ARTIFACT_RIGHT_PANE_MAX_WIDTH: 720, - ARTIFACT_RIGHT_PANE_MIN_WIDTH: 360, + ARTIFACT_RIGHT_PANE_MIN_WIDTH: 280, RightPaneHost: ({ children, open, @@ -112,7 +112,7 @@ vi.mock('@renderer/components/chat/shell/RightPaneHost', () => ({ className?: string }>) => (
{ 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', '280') 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', '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__/AgentPage.test.tsx b/src/renderer/pages/agents/__tests__/AgentPage.test.tsx index 7f227ec34f4..a11dbdfe679 100644 --- a/src/renderer/pages/agents/__tests__/AgentPage.test.tsx +++ b/src/renderer/pages/agents/__tests__/AgentPage.test.tsx @@ -58,9 +58,11 @@ const agentPageMocks = vi.hoisted(() => ({ setLastUsedAgentId: vi.fn(), setLastUsedSessionId: vi.fn(), setLastUsedWorkspaceId: vi.fn(), + setSessionExpansionAgent: vi.fn(), setClassicLayoutRightPaneOpen: vi.fn(), setShowSidebar: vi.fn(), - sessionLayout: 'modern' as 'modern' | 'classic', + sessionDisplayMode: 'time' as 'time' | 'workdir' | 'agent', + sessionPanePosition: 'right' as 'left' | 'right', isActiveTab: false, showSidebar: false, routeSearch: { sessionId: 'session-initial' } as Record, @@ -77,7 +79,8 @@ const agentPageMocks = vi.hoisted(() => ({ updatedAt: string workspaceId?: string workspace?: { type?: string } - }> + }>, + sessionExpansionAgent: [] as string[] })) const activeSessionMocks = vi.hoisted(() => ({ @@ -115,14 +118,20 @@ vi.mock('@data/hooks/usePreference', async () => { const [value, setValue] = React.useState( key === 'topic.tab.show' ? agentPageMocks.showSidebar - : key === 'agent.layout' - ? agentPageMocks.sessionLayout - : undefined + : key === 'agent.session.display_mode' + ? agentPageMocks.sessionDisplayMode + : key === 'agent.session.position' + ? agentPageMocks.sessionPanePosition + : undefined ) const setPreference = vi.fn(async (nextValue: unknown) => { if (key === 'topic.tab.show') { agentPageMocks.showSidebar = Boolean(nextValue) agentPageMocks.setShowSidebar(nextValue) + } else if (key === 'agent.session.display_mode') { + agentPageMocks.sessionDisplayMode = nextValue as 'time' | 'workdir' | 'agent' + } else if (key === 'agent.session.position') { + agentPageMocks.sessionPanePosition = nextValue as 'left' | 'right' } setValue(nextValue) }) @@ -148,6 +157,8 @@ vi.mock('@renderer/data/hooks/useCache', async () => { return agentPageMocks.lastUsedWorkspaceId case 'ui.agent.right_pane_open': return agentPageMocks.classicLayoutRightPaneOpen + case 'ui.agent.session.expansion.agent': + return agentPageMocks.sessionExpansionAgent default: return undefined } @@ -157,12 +168,13 @@ vi.mock('@renderer/data/hooks/useCache', async () => { key !== 'ui.agent.last_used_agent_id' && key !== 'ui.agent.last_used_session_id' && key !== 'ui.agent.last_used_workspace_id' && - key !== 'ui.agent.right_pane_open' + key !== 'ui.agent.right_pane_open' && + key !== 'ui.agent.session.expansion.agent' ) { return [undefined, vi.fn()] } - const setCache = vi.fn((nextValue: string | boolean | null) => { + const setCache = vi.fn((nextValue: string | boolean | string[] | null) => { if (key === 'ui.agent.last_used_agent_id') { agentPageMocks.lastUsedAgentId = nextValue as string | null agentPageMocks.setLastUsedAgentId(nextValue) @@ -172,6 +184,9 @@ vi.mock('@renderer/data/hooks/useCache', async () => { } else if (key === 'ui.agent.right_pane_open') { agentPageMocks.classicLayoutRightPaneOpen = nextValue as boolean agentPageMocks.setClassicLayoutRightPaneOpen(nextValue) + } else if (key === 'ui.agent.session.expansion.agent') { + agentPageMocks.sessionExpansionAgent = nextValue as string[] + agentPageMocks.setSessionExpansionAgent(nextValue) } else { agentPageMocks.lastUsedWorkspaceId = nextValue as string | null agentPageMocks.setLastUsedWorkspaceId(nextValue) @@ -309,6 +324,7 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => ({ + 'agent.manage.title': '管理智能体', 'agent.session.list.title': '任务' })[key] ?? key }) @@ -332,6 +348,7 @@ vi.mock('../AgentChat', () => ({ locateMessageId, pane, paneOpen, + panePosition, resourcePaneCount, resourcePane, showResourceListControls, @@ -363,6 +380,7 @@ vi.mock('../AgentChat', () => ({ locateMessageId?: string pane?: ReactNode paneOpen?: boolean + panePosition?: string resourcePaneCount?: { label: string; count: number } resourcePane?: { node?: ReactNode; label?: string } | null showResourceListControls?: boolean @@ -380,6 +398,7 @@ vi.mock('../AgentChat', () => ({ {String(Boolean(missingAgentDraft))} {locateMessageId ?? ''} {String(paneOpen)} + {panePosition ?? ''} {String(sessionPaneOpen)} {String(showResourceListControls)} {resourcePaneCount && ( @@ -459,7 +478,9 @@ vi.mock('../components/AgentChatNavbar', () => ({ vi.mock('../AgentSidePanel', () => ({ default: ({ activeSessionId, + onAddAgent, onOpenHistoryRecords, + onSetPanePosition, onStartDraftSession, onStartMissingAgentDraft, revealRequest, @@ -473,7 +494,7 @@ vi.mock('../AgentSidePanel', () => ({ data-testid="agent-side-panel"> + + @@ -503,7 +530,7 @@ vi.mock('../AgentSidePanel', () => ({ {resourceMenuItems?.map((item: { id: string; label: ReactNode; onSelect: () => void | Promise }) => ( ))}
@@ -516,11 +543,12 @@ vi.mock('@renderer/components/chat/resourceList/AgentResourceList', () => ({ activeAgentId, onAddAgent, onActiveAgentDeleted, - resourceMenuItems + onSelectedAgentClick }: { activeAgentId?: string | null onAddAgent?: () => void | Promise onActiveAgentDeleted?: (agentId: string) => void | Promise + onSelectedAgentClick?: () => void | Promise resourceMenuItems?: Array<{ id: string; label: ReactNode; onSelect: () => void | Promise }> }) => (
@@ -530,11 +558,9 @@ vi.mock('@renderer/components/chat/resourceList/AgentResourceList', () => ({ - {resourceMenuItems?.map((item) => ( - - ))} +
) })) @@ -551,12 +577,23 @@ vi.mock('../components/AgentConversationPickerDialog', () => ({ })) vi.mock('../components/Sessions', () => ({ - default: ({ agentIdFilter, presentation }: { agentIdFilter?: string | null; presentation?: string }) => ( + default: ({ + agentIdFilter, + onSetPanePosition, + presentation + }: { + agentIdFilter?: string | null + onSetPanePosition?: (position: 'left' | 'right') => void | Promise + presentation?: string + }) => (
+ data-testid="session-resource-panel"> + +
) })) @@ -583,10 +620,12 @@ describe('AgentPage', () => { agentPageMocks.currentTab = undefined agentPageMocks.lastUsedAgentId = null agentPageMocks.lastUsedWorkspaceId = null + agentPageMocks.sessionExpansionAgent = [] agentPageMocks.classicLayoutRightPaneOpen = true agentPageMocks.activeSessionOptions = null agentPageMocks.focusExistingTab.mockReturnValue(false) - agentPageMocks.sessionLayout = 'modern' + agentPageMocks.sessionDisplayMode = 'time' + agentPageMocks.sessionPanePosition = 'right' agentPageMocks.showSidebar = false agentPageMocks.isActiveTab = false agentPageMocks.dataApiGet.mockImplementation(async (path: string) => { @@ -616,7 +655,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' @@ -629,12 +668,114 @@ describe('AgentPage', () => { expect(screen.queryByTestId('agent-side-panel')).not.toBeInTheDocument() }) + it('hides resource management entries from the left rail when sessions are on the right', () => { + agentPageMocks.sessionDisplayMode = 'agent' + agentPageMocks.sessionPanePosition = 'right' + activeSessionMocks.session = { ...agentPageMocks.persistedSession, agentId: 'agent-a' } + activeSessionMocks.sessionSource = 'query' + + render() + + expect(screen.getByTestId('agent-resource-list')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'agent.manage.title' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'chat.resource_view.menu.skill' })).not.toBeInTheDocument() + }) + + it('does not render the session resource pane when the classic session position is left', () => { + agentPageMocks.sessionDisplayMode = 'agent' + agentPageMocks.sessionPanePosition = 'left' + agentPageMocks.classicLayoutRightPaneOpen = true + 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() + expect(screen.queryByTestId('resource-pane-count')).not.toBeInTheDocument() + }) + + it('does not auto-open the session right pane when switching to agent display mode with left session position', () => { + agentPageMocks.sessionDisplayMode = 'agent' + agentPageMocks.sessionPanePosition = 'left' + agentPageMocks.classicLayoutRightPaneOpen = false + activeSessionMocks.session = { ...agentPageMocks.persistedSession, agentId: 'agent-a' } + activeSessionMocks.sessionSource = 'query' + + render() + + expect(screen.getByTestId('session-pane-open')).toHaveTextContent('false') + expect(agentPageMocks.setClassicLayoutRightPaneOpen).not.toHaveBeenCalledWith(true) + }) + + it('toggles the classic session pane when the selected agent is clicked again', () => { + agentPageMocks.sessionDisplayMode = 'agent' + agentPageMocks.classicLayoutRightPaneOpen = true + activeSessionMocks.session = { ...agentPageMocks.persistedSession, agentId: 'agent-a' } + activeSessionMocks.sessionSource = 'query' + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Toggle selected agent pane' })) + + expect(agentPageMocks.setClassicLayoutRightPaneOpen).toHaveBeenCalledWith(false) + }) + + it('renders the modern session sidebar when session display mode is time', () => { + agentPageMocks.sessionDisplayMode = 'time' + agentPageMocks.sessionPanePosition = 'right' + 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() + expect(screen.getByTestId('pane-position')).toHaveTextContent('left') + }) + + it('switches to agent grouping when changing session position from the left sidebar', async () => { + agentPageMocks.sessionDisplayMode = 'workdir' + agentPageMocks.sessionPanePosition = 'left' + activeSessionMocks.session = { ...agentPageMocks.persistedSession, agentId: 'agent-a' } + activeSessionMocks.sessionSource = 'query' + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Move sessions right' })) + + await waitFor(() => expect(agentPageMocks.sessionDisplayMode).toBe('agent')) + expect(agentPageMocks.sessionPanePosition).toBe('right') + expect(agentPageMocks.setClassicLayoutRightPaneOpen).toHaveBeenCalledWith(true) + }) + + it('expands only the active session agent when changing session position to the left sidebar', async () => { + agentPageMocks.sessionDisplayMode = 'agent' + agentPageMocks.sessionPanePosition = 'right' + activeSessionMocks.session = { ...agentPageMocks.persistedSession, id: 'session-a', agentId: 'agent-a' } + activeSessionMocks.sessionSource = 'query' + agentPageMocks.classicLayoutSessions = [ + { ...agentPageMocks.persistedSession, id: 'session-a', agentId: 'agent-a' }, + { ...agentPageMocks.persistedSession, id: 'session-b', agentId: 'agent-b' }, + { ...agentPageMocks.persistedSession, id: 'session-c', agentId: 'agent-c' } + ] + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Move sessions left' })) + + await waitFor(() => expect(agentPageMocks.sessionPanePosition).toBe('left')) + expect(agentPageMocks.sessionExpansionAgent).toEqual(['session:agent:agent-b', 'session:agent:agent-c']) + }) + it('renders the agent resource view outside AgentChat runtime', () => { activeSessionMocks.session = { ...agentPageMocks.persistedSession, agentId: 'agent-a' } 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() @@ -642,13 +783,14 @@ describe('AgentPage', () => { }) it('keeps the agent resource view open while opening the classic-layout agent picker', () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' + agentPageMocks.sessionPanePosition = 'left' activeSessionMocks.session = { ...agentPageMocks.persistedSession, agentId: 'agent-a' } activeSessionMocks.sessionSource = 'query' 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() @@ -657,7 +799,8 @@ describe('AgentPage', () => { }) it('keeps the agent resource view open until the selected agent session is ready', async () => { - agentPageMocks.sessionLayout = 'classic' + agentPageMocks.sessionDisplayMode = 'agent' + agentPageMocks.sessionPanePosition = 'left' agentPageMocks.routeSearch = {} agentPageMocks.agents = [ { id: 'agent-a', model: 'model-a', name: 'Agent A' }, @@ -674,7 +817,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' })) @@ -710,13 +853,56 @@ describe('AgentPage', () => { expect(screen.queryByTestId('resource-catalog-agent')).not.toBeInTheDocument() }) + it('prevents duplicate empty session creation from rapid classic-layout picker selection', async () => { + agentPageMocks.sessionDisplayMode = 'agent' + agentPageMocks.sessionPanePosition = 'left' + agentPageMocks.routeSearch = {} + agentPageMocks.agents = [ + { id: 'agent-a', model: 'model-a', name: 'Agent A' }, + { id: 'agent-b', model: 'model-b', name: 'Agent B' } + ] + activeSessionMocks.session = { ...agentPageMocks.persistedSession, agentId: 'agent-a' } + activeSessionMocks.sessionSource = 'query' + let resolveSession!: (session: unknown) => void + agentPageMocks.dataApiPost.mockReturnValue( + new Promise((resolve) => { + resolveSession = resolve + }) + ) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'agent.manage.title' })) + fireEvent.click(screen.getByRole('button', { name: 'Open agent picker' })) + const selectAgentButton = screen.getByRole('button', { name: 'Select resource agent' }) + fireEvent.click(selectAgentButton) + fireEvent.click(selectAgentButton) + + await waitFor(() => expect(agentPageMocks.dataApiPost).toHaveBeenCalledTimes(1)) + + await act(async () => { + resolveSession({ + ...agentPageMocks.persistedSession, + id: 'session-picker', + agentId: 'agent-b', + workspaceId: undefined, + workspace: { + type: 'system', + name: 'No project', + path: '' + } + }) + await Promise.resolve() + }) + }) + it('keeps a sidebar toggle beside agent resource search so a collapsed pane can be reopened', async () => { agentPageMocks.showSidebar = true activeSessionMocks.session = { ...agentPageMocks.persistedSession, agentId: 'agent-a' } 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') @@ -731,8 +917,8 @@ describe('AgentPage', () => { await waitFor(() => expect(within(shell).getByTestId('resource-pane-open')).toHaveTextContent('true')) }) - it('restores and records the classic-layout agent right pane open state from cache', () => { - agentPageMocks.sessionLayout = 'classic' + it('preserves a manually closed classic-layout agent right pane across remounts', async () => { + agentPageMocks.sessionDisplayMode = 'agent' agentPageMocks.classicLayoutRightPaneOpen = false activeSessionMocks.session = { ...agentPageMocks.persistedSession, agentId: 'agent-a' } activeSessionMocks.sessionSource = 'query' @@ -740,6 +926,7 @@ describe('AgentPage', () => { render() expect(screen.getByTestId('session-pane-open')).toHaveTextContent('false') + expect(agentPageMocks.setClassicLayoutRightPaneOpen).not.toHaveBeenCalledWith(true) fireEvent.click(screen.getByRole('button', { name: 'Close session pane' })) @@ -747,7 +934,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 = [ @@ -764,7 +951,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 = [ { @@ -788,7 +975,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' } @@ -824,7 +1011,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' }, @@ -862,7 +1049,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 = [ @@ -896,7 +1083,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' }, @@ -932,7 +1119,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' }, @@ -967,7 +1154,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', @@ -998,7 +1185,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', @@ -1044,8 +1231,46 @@ describe('AgentPage', () => { expect(agentPageMocks.activeSessionOptions?.activeSessionId).toBe('session-composer-empty') }) + it('prevents duplicate empty session creation from rapid classic-layout composer clicks', async () => { + agentPageMocks.sessionDisplayMode = 'agent' + activeSessionMocks.session = { + ...agentPageMocks.persistedSession, + id: 'session-active', + agentId: 'agent-a', + workspaceId: 'workspace-a', + workspace: agentPageMocks.workspace + } + activeSessionMocks.sessionSource = 'query' + let resolveSession!: (session: unknown) => void + agentPageMocks.dataApiPost.mockReturnValue( + new Promise((resolve) => { + resolveSession = resolve + }) + ) + + render() + + const createSessionButton = screen.getByRole('button', { name: 'Create empty session from composer' }) + fireEvent.click(createSessionButton) + fireEvent.click(createSessionButton) + + await waitFor(() => expect(agentPageMocks.dataApiPost).toHaveBeenCalledTimes(1)) + + await act(async () => { + resolveSession({ + ...agentPageMocks.persistedSession, + id: 'session-composer-empty', + agentId: 'agent-a', + name: '', + workspaceId: 'workspace-a', + workspace: agentPageMocks.workspace + }) + await Promise.resolve() + }) + }) + 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', @@ -1089,7 +1314,7 @@ describe('AgentPage', () => { } }) ) - expect(agentPageMocks.activeSessionOptions?.activeSessionId).toBe('session-composer-empty') + await waitFor(() => expect(agentPageMocks.activeSessionOptions?.activeSessionId).toBe('session-composer-empty')) expect(screen.getByTestId('active-session')).toHaveTextContent('session-composer-empty') expect(agentPageMocks.invalidateCache).toHaveBeenCalledWith([ '/agent-sessions', @@ -1099,7 +1324,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', @@ -1124,7 +1349,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', @@ -1154,7 +1379,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' }, @@ -1277,14 +1502,15 @@ describe('AgentPage', () => { expect(screen.getByTestId('locate-message-id')).toHaveTextContent('') }) - it('opens the session pane when a global-search locate targets a session in the current tab', () => { - agentPageMocks.sessionLayout = 'classic' + it('opens the session pane when a global-search locate targets a session in the current tab', async () => { + agentPageMocks.sessionDisplayMode = 'agent' agentPageMocks.classicLayoutRightPaneOpen = false agentPageMocks.focusExistingTab.mockReturnValue(false) render() expect(screen.getByTestId('session-pane-open')).toHaveTextContent('false') + expect(agentPageMocks.setClassicLayoutRightPaneOpen).not.toHaveBeenCalledWith(true) const sessionMessageHandler = vi .mocked(EventEmitter.on) @@ -1339,6 +1565,18 @@ describe('AgentPage', () => { expect(screen.getByTestId('pane-open')).toHaveTextContent('false') }) + 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.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', () => { agentPageMocks.showSidebar = true diff --git a/src/renderer/pages/agents/components/AgentChatNavbar/AgentChatNavbar.tsx b/src/renderer/pages/agents/components/AgentChatNavbar/AgentChatNavbar.tsx index ebea8d15c75..5bcfc8ad9ed 100644 --- a/src/renderer/pages/agents/components/AgentChatNavbar/AgentChatNavbar.tsx +++ b/src/renderer/pages/agents/components/AgentChatNavbar/AgentChatNavbar.tsx @@ -29,11 +29,7 @@ const AgentChatNavbar = ({ }) return ( - +
void onOpenInNewWindow?: (session: AgentSessionEntity) => void onPress: (id: string) => void - onSelectItem?: () => void + onSetPanePosition?: (position: TopicTabPosition) => void | Promise onTogglePin?: (id: string) => void | Promise + panePosition?: TopicTabPosition pinned?: boolean reserveLeadingIconSlot?: boolean session: AgentSessionEntity @@ -42,13 +45,16 @@ const SessionItem = ({ onOpenInNewTab, onOpenInNewWindow, onPress, - onSelectItem, + onSetPanePosition, + panePosition, onTogglePin, pinned = false, reserveLeadingIconSlot = true, session }: SessionItemProps) => { const { t } = useTranslation() + const shellState = useOptionalShellState() + const shellActions = useOptionalShellActions() const actions = useResourceListActions() const rowState = useResourceListRowState(session.id) const topicId = useMemo(() => buildAgentSessionTopicId(session.id), [session.id]) @@ -104,7 +110,9 @@ const SessionItem = ({ onDelete: handleDelete, onOpenInNewTab: onOpenInNewTab ? handleOpenInNewTab : undefined, onOpenInNewWindow: onOpenInNewWindow ? handleOpenInNewWindow : undefined, + onSetPanePosition, onTogglePin: onTogglePin ? handleTogglePin : undefined, + panePosition, pinned, sessionName: session.name ?? '', startEdit: startMenuEdit, @@ -118,7 +126,9 @@ const SessionItem = ({ active, onOpenInNewTab, onOpenInNewWindow, + onSetPanePosition, onTogglePin, + panePosition, pinned, session.name, startMenuEdit, @@ -166,10 +176,10 @@ const SessionItem = ({ handleOpenInNewTab() return } + if (shellState?.maximized) shellActions?.minimize() onPress(session.id) - onSelectItem?.() }, - [active, handleOpenInNewTab, onOpenInNewTab, onPress, onSelectItem, session.id] + [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 95cff95c254..adb04ec4f83 100644 --- a/src/renderer/pages/agents/components/Sessions.tsx +++ b/src/renderer/pages/agents/components/Sessions.tsx @@ -1,31 +1,24 @@ -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 { - ConversationResourceMenu, type ConversationResourceMenuItem, remapResourceListCollapsedGroupIds, + renderAgentEntityIcon, + resolveDefaultCollapsedGroupIds, RESOURCE_LIST_RIGHT_PANEL_SEARCH_INPUT_CLASS, ResourceList, type ResourceListGroup, type ResourceListItemReorderPayload, type ResourceListReorderPayload, type ResourceListRevealRequest, - type ResourceListSection + type ResourceListSection, + SESSION_DISPLAY_LABEL_KEYS, + SessionListOptionsMenu } from '@renderer/components/chat/resourceList/base' import { SessionResourceList } from '@renderer/components/chat/resourceList/SessionResourceList' import { CommandPopupMenu } from '@renderer/components/command' import EditNameDialog from '@renderer/components/EditNameDialog' -import EmojiIcon from '@renderer/components/EmojiIcon' import { ResourceEditDialogHost, type ResourceEditDialogTarget @@ -39,8 +32,8 @@ import { useAgentSessionsSource } from '@renderer/hooks/resourceViewSources' import { useCurrentTabId } from '@renderer/hooks/tab' import { useConversationNavigation } from '@renderer/hooks/useConversationNavigation' import { usePins } from '@renderer/hooks/usePins' -import { getAgentAvatarFromConfiguration } from '@renderer/utils/agent' import { formatErrorMessage, formatErrorMessageWithPrefix } from '@renderer/utils/error' +import { pickNeighbourAfterRemoval } from '@renderer/utils/resourceEntity' import { cn } from '@renderer/utils/style' import type { AgentSessionEntity } from '@shared/data/api/schemas/agentSessions' import { @@ -48,19 +41,9 @@ 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 type { AssistantIconType, TopicTabPosition } from '@shared/data/preference/preferenceTypes' +import { Folder, FolderOpen, MoreHorizontal, Plus, 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' @@ -100,10 +83,13 @@ import { type SessionsBaseProps = { agentIdFilter?: string | null + onActiveAgentDeleted?: (agentId: string) => void | Promise + onAddAgent?: () => void | Promise onOpenHistoryRecords?: () => void - onSelectItem?: () => void + onSetPanePosition?: (position: TopicTabPosition) => void | Promise onStartDraftSession?: (defaults: DraftAgentSessionDefaults) => void | Promise onStartMissingAgentDraft?: () => void | Promise + panePosition?: TopicTabPosition presentation?: 'sidebar' | 'right-panel' revealRequest?: ResourceListRevealRequest resourceMenuItems?: readonly ConversationResourceMenuItem[] @@ -118,120 +104,45 @@ 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[] = [] +const DEFAULT_SESSION_GROUP_VISIBLE_COUNT = 5 +const LEFT_PANEL_TIME_SESSION_GROUP_VISIBLE_COUNT = 50 + type CreateSessionSeed = { agentId: string workspace?: AgentSessionWorkspaceSource 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, + assistantIconType, + deleteAgentDisabled, pinDisabled, pinned, - onDeleteSessions, + onDeleteAgent, onEdit, + onSetAgentIconType, onTogglePin }: { agentId: string - deleteSessionsDisabled?: boolean + assistantIconType: AssistantIconType + deleteAgentDisabled?: boolean pinDisabled?: boolean pinned: boolean - onDeleteSessions: (agentId: string) => void | Promise + onDeleteAgent: (agentId: string) => void | Promise onEdit: (agentId: string) => void + onSetAgentIconType: (iconType: AssistantIconType) => void | Promise onTogglePin: (agentId: string) => void | Promise }) { const { t } = useTranslation() const actionContext: AgentGroupActionContext = { agentId, - deleteSessionsDisabled, - onDeleteSessions, + assistantIconType, + deleteAgentDisabled, + onDeleteAgent, onEdit, + onSetAgentIconType, onTogglePin, pinDisabled, pinned, @@ -352,10 +263,13 @@ export function findLatestCreateSessionSeed( const Sessions = ({ activeSessionId, agentIdFilter, + onActiveAgentDeleted, + onAddAgent, onOpenHistoryRecords, - onSelectItem, + onSetPanePosition, onStartDraftSession, onStartMissingAgentDraft, + panePosition, presentation = 'sidebar', revealRequest, resourceMenuItems, @@ -366,6 +280,12 @@ const Sessions = ({ const conversationNav = useConversationNavigation('agents') const [groupNow] = useState(() => new Date()) const [sessionDisplayMode, setSessionDisplayMode] = usePreference('agent.session.display_mode') + const [storedPanePosition, setStoredPanePosition] = usePreference('agent.session.position') + // Agent session icon style is stored under its own key so it no longer mutates the assistant's. + const [assistantIconType, setAssistantIconType] = usePreference('agent.icon_type') + const [defaultModelId] = usePreference('chat.default_model_id') + const resolvedPanePosition = panePosition ?? storedPanePosition + const setResolvedPanePosition = onSetPanePosition ?? setStoredPanePosition const [sessionExpansionTime, setSessionExpansionTime] = usePersistCache('ui.agent.session.expansion.time') const [sessionExpansionAgent, setSessionExpansionAgent] = usePersistCache('ui.agent.session.expansion.agent') const [sessionExpansionWorkdir, setSessionExpansionWorkdir] = usePersistCache('ui.agent.session.expansion.workdir') @@ -392,8 +312,7 @@ const Sessions = ({ const [optimisticAgentOrderIds, setOptimisticAgentOrderIds] = useState(null) const [optimisticWorkspaceOrderIds, setOptimisticWorkspaceOrderIds] = useState(null) const [creatingSession, setCreatingSession] = useState(false) - const [deletingAgentGroupId, setDeletingAgentGroupId] = useState(null) - const deletingAgentGroupIdRef = useRef(null) + const [deletingAgentId, setDeletingAgentId] = useState(null) const [deletingWorkspaceGroupId, setDeletingWorkspaceGroupId] = useState(null) const [renamingWorkspaceGroup, setRenamingWorkspaceGroup] = useState<{ name: string @@ -415,6 +334,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 @@ -634,15 +557,21 @@ const Sessions = ({ }, [displayMode, t]) const collapsedSessionState = useMemo(() => { + const resolvedSessionExpansion = resolveDefaultCollapsedGroupIds({ + collapsedIds: sessionExpansion, + groupBy: sessionGroupBy, + items: filteredGroupedSessions + }) + if (displayMode !== 'workdir') { - return sessionExpansion + return resolvedSessionExpansion } - return remapResourceListCollapsedGroupIds(sessionExpansion, (groupId) => { + return remapResourceListCollapsedGroupIds(resolvedSessionExpansion, (groupId) => { const path = getWorkdirPathFromSessionGroupId(groupId) return path ? (workdirDisplay.groupIdByPath.get(path) ?? groupId) : groupId }) - }, [displayMode, sessionExpansion, workdirDisplay]) + }, [displayMode, filteredGroupedSessions, sessionExpansion, sessionGroupBy, workdirDisplay]) const handleSessionCollapsedStateChange = useCallback( (nextCollapsedIds: string[]) => { @@ -667,52 +596,50 @@ const Sessions = ({ const success = await deleteSession(id) if (!success || activeSessionId !== id) return - if (isRightPanel) { - // Classic layout is scoped to a single agent: select that agent's neighbouring session rather than - // the first remaining session, which could belong to another agent. - const index = filteredGroupedSessions.findIndex((s) => s.id === id) - const next = - index !== -1 && filteredGroupedSessions.length > 1 - ? filteredGroupedSessions[index + 1 === filteredGroupedSessions.length ? index - 1 : index + 1] - : undefined - if (next) { - setActiveSessionId(next.id) - return - } + // Select the neighbouring session in the visible display order. Classic layout is agent-scoped + // via filteredGroupedSessions; modern uses the full grouped list (filteredGroupedSessions === + // groupedSessions there). This keeps agent-session deletion consistent with topic deletion + // instead of falling back to the raw API/orderKey head. + const next = pickNeighbourAfterRemoval(filteredGroupedSessions, id) + if (next) { + setActiveSessionId(next.id) + return + } - const deletedSession = - (index !== -1 ? filteredGroupedSessions[index] : undefined) ?? - sessionItemsRef.current.find((session) => session.id === id) - const seed = deletedSession - ? buildCreateSessionSeed({ - agentId: agentIdFilter ?? deletedSession.agentId, - workspace: deletedSession.workspace, - workspaceId: deletedSession.workspaceId - }) - : agentIdFilter - ? { agentId: agentIdFilter, workspace: { type: AGENT_WORKSPACE_TYPE.SYSTEM } } - : null - // Mirror the sibling create paths (createSessionFromSeed / handleRenameSession): if the - // draft start rejects (e.g. the user-workspace refetch fails) surface a toast and still - // clear the active id in `finally`, so we never strand the view on the just-deleted session. - try { - if (seed?.agentId && onStartDraftSession) { - await onStartDraftSession({ - agentId: seed.agentId, - workspace: seed.workspace ?? { type: AGENT_WORKSPACE_TYPE.SYSTEM } - }) - } - } catch (err) { - logger.error('Failed to start draft session after deleting last session', { err, sessionId: id }) - window.toast.error(formatErrorMessageWithPrefix(err, t('agent.session.create.error.failed'))) - } finally { - setActiveSessionId(null) - } + if (!isRightPanel) { + setActiveSessionId(null) return } - const remaining = sessionItems.find((s) => s.id !== id) - setActiveSessionId(remaining?.id ?? null) + // Classic layout scoped to a single agent and now empty: start a fresh draft session for it. + const deletedSession = + filteredGroupedSessions.find((session) => session.id === id) ?? + sessionItemsRef.current.find((session) => session.id === id) + const seed = deletedSession + ? buildCreateSessionSeed({ + agentId: agentIdFilter ?? deletedSession.agentId, + workspace: deletedSession.workspace, + workspaceId: deletedSession.workspaceId + }) + : agentIdFilter + ? { agentId: agentIdFilter, workspace: { type: AGENT_WORKSPACE_TYPE.SYSTEM } } + : null + // Mirror the sibling create paths (createSessionFromSeed / handleRenameSession): if the + // draft start rejects (e.g. the user-workspace refetch fails) surface a toast and still + // clear the active id in `finally`, so we never strand the view on the just-deleted session. + try { + if (seed?.agentId && onStartDraftSession) { + await onStartDraftSession({ + agentId: seed.agentId, + workspace: seed.workspace ?? { type: AGENT_WORKSPACE_TYPE.SYSTEM } + }) + } + } catch (err) { + logger.error('Failed to start draft session after deleting last session', { err, sessionId: id }) + window.toast.error(formatErrorMessageWithPrefix(err, t('agent.session.create.error.failed'))) + } finally { + setActiveSessionId(null) + } }, [ activeSessionId, @@ -721,7 +648,6 @@ const Sessions = ({ filteredGroupedSessions, isRightPanel, onStartDraftSession, - sessionItems, setActiveSessionId, t ] @@ -762,8 +688,8 @@ const Sessions = ({ const { trigger: deleteWorkspace } = useMutation('DELETE', '/agent-workspaces/:workspaceId', { refresh: ['/agent-sessions', '/agent-workspaces', '/pins', '/agent-channels'] }) - const { trigger: deleteAgentSessions } = useMutation('DELETE', '/agents/:agentId/sessions', { - refresh: ['/agent-sessions', '/agent-workspaces', '/pins', '/agent-channels'] + const { trigger: deleteAgent } = useMutation('DELETE', '/agents/:agentId', { + refresh: ['/agents', '/agent-sessions', '/agent-workspaces', '/pins', '/agent-channels'] }) const { trigger: reorderWorkspace } = useMutation('PATCH', '/agent-workspaces/:id/order') const { trigger: reorderAgent } = useMutation('PATCH', '/agents/:id/order', { refresh: ['/agents'] }) @@ -838,22 +764,20 @@ const Sessions = ({ } }, [displayMode, refetchWorkspaces, reload]) - const handleDeleteAgentSessions = useCallback( + const handleDeleteAgent = useCallback( async (agentId: string) => { - if (deletingAgentGroupIdRef.current) return - - const sessionIds = sessionItemsRef.current - .filter((session) => session.agentId === agentId) - .map((session) => session.id) - if (sessionIds.length === 0) return + if (deletingAgentId) return - deletingAgentGroupIdRef.current = agentId - setDeletingAgentGroupId(agentId) + const currentActiveSessionId = activeSessionIdRef.current + const currentActiveSession = currentActiveSessionId + ? sessionItemsRef.current.find((session) => session.id === currentActiveSessionId) + : undefined + setDeletingAgentId(agentId) try { const confirmed = await window.modal.confirm({ - title: t('agent.session.agent.delete.title'), - content: t('agent.session.agent.delete.content'), + title: t('agent.delete.title'), + content: t('agent.delete.content'), okText: t('common.delete'), cancelText: t('common.cancel'), centered: true, @@ -863,27 +787,37 @@ const Sessions = ({ }) if (!confirmed) return - const result = await deleteAgentSessions({ params: { agentId } }) - const affectedSessionIds = new Set(result.deletedIds) - const currentActiveSessionId = activeSessionIdRef.current - - if (currentActiveSessionId && affectedSessionIds.has(currentActiveSessionId)) { - const remaining = sessionItemsRef.current.find((session) => !affectedSessionIds.has(session.id)) - setActiveSessionId(remaining?.id ?? null) + await deleteAgent({ params: { agentId }, query: { deleteSessions: true } }) + if (currentActiveSession?.agentId === agentId) { + if (onActiveAgentDeleted) { + await onActiveAgentDeleted(agentId) + } else { + const remaining = sessionItemsRef.current.find((session) => session.agentId !== agentId) + setActiveSessionId(remaining?.id ?? null) + } } + await refetchAgents() await reload() await refetchWorkspaces() window.toast.success(t('common.delete_success')) } catch (err) { - logger.error('Failed to delete agent sessions', { agentId, err, sessionIds }) - window.toast.error(formatErrorMessageWithPrefix(err, t('agent.session.agent.delete.error.failed'))) + logger.error('Failed to delete agent from session group', { agentId, err }) + window.toast.error(formatErrorMessageWithPrefix(err, t('agent.delete.error.failed'))) } finally { - deletingAgentGroupIdRef.current = null - setDeletingAgentGroupId(null) + setDeletingAgentId(null) } }, - [deleteAgentSessions, refetchWorkspaces, reload, setActiveSessionId, t] + [ + deleteAgent, + deletingAgentId, + onActiveAgentDeleted, + refetchAgents, + refetchWorkspaces, + reload, + setActiveSessionId, + t + ] ) const handleDeleteWorkdirGroup = useCallback( @@ -1221,7 +1155,6 @@ const Sessions = ({ const createSessionSeed = getCreateSessionSeedForGroup(group.id) const canCreateSession = createSessionSeed !== null && agentById.has(createSessionSeed.agentId) const canManageAgentGroup = !!agentGroupId && agentById.has(agentGroupId) - const hasAgentSessions = !!agentGroupId && sessionItems.some((session) => session.agentId === agentGroupId) if (!canCreateSession && !workdirPath && !canManageAgentGroup) return null @@ -1231,11 +1164,13 @@ const Sessions = ({ @@ -1275,13 +1210,14 @@ const Sessions = ({ [ agentById, agentPinnedIdSet, + assistantIconType, createSessionFromSeed, creatingSession, - deletingAgentGroupId, + deletingAgentId, deletingWorkspaceGroupId, displayMode, getCreateSessionSeedForGroup, - handleDeleteAgentSessions, + handleDeleteAgent, handleToggleAgentPin, handleDeleteWorkdirGroup, handleOpenWorkdirGroup, @@ -1289,7 +1225,7 @@ const Sessions = ({ isAgentPinActionDisabled, isUpdatingWorkspace, openAgentEditor, - sessionItems, + setAssistantIconType, t, workdirDisplay ] @@ -1342,16 +1278,9 @@ const Sessions = ({ const agentId = getAgentIdFromSessionGroupId(group.id) const agent = agentId ? agentById.get(agentId) : undefined - return ( - - ) + return renderAgentEntityIcon(assistantIconType, agent, defaultModelId) }, - [agentById, displayMode] + [agentById, assistantIconType, defaultModelId, displayMode] ) const getGroupHeaderClassName = useCallback( @@ -1388,10 +1317,11 @@ const Sessions = ({ const actionContext: AgentGroupActionContext = { agentId, - deleteSessionsDisabled: - !!deletingAgentGroupId || !sessionItems.some((session) => session.agentId === agentId), - onDeleteSessions: handleDeleteAgentSessions, + assistantIconType, + deleteAgentDisabled: deletingAgentId !== null, + onDeleteAgent: handleDeleteAgent, onEdit: openAgentEditor, + onSetAgentIconType: setAssistantIconType, onTogglePin: handleToggleAgentPin, pinDisabled: isAgentPinActionDisabled, pinned: agentPinnedIdSet.has(agentId), @@ -1430,10 +1360,11 @@ const Sessions = ({ [ agentById, agentPinnedIdSet, - deletingAgentGroupId, + assistantIconType, + deletingAgentId, deletingWorkspaceGroupId, displayMode, - handleDeleteAgentSessions, + handleDeleteAgent, handleDeleteWorkdirGroup, handleOpenWorkdirGroup, handleStartRenameWorkdirGroup, @@ -1441,7 +1372,7 @@ const Sessions = ({ isAgentPinActionDisabled, isUpdatingWorkspace, openAgentEditor, - sessionItems, + setAssistantIconType, t, workdirDisplay ] @@ -1468,6 +1399,13 @@ const Sessions = ({ ? 'empty' : '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') + const headerCreateLabel = displayMode === 'agent' ? t('agent.add.title') : t('agent.session.new') + const headerCreateDisabled = + displayMode === 'agent' ? !onAddAgent : creatingSession || (!headerCreateSessionSeed && !onStartMissingAgentDraft) + const handleHeaderCreate = displayMode === 'agent' ? () => void onAddAgent?.() : handleHeaderCreateSession + const canSetPanePosition = displayMode === 'agent' || isRightPanel return ( @@ -1480,8 +1418,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} @@ -1505,7 +1443,7 @@ const Sessions = ({ onGroupHeaderSelectItem={handleSelectSession} onReorder={handleSessionReorder} onCollapsedStateChange={handleSessionCollapsedStateChange}> - + {isRightPanel ? ( } - label={t('agent.session.new')} - onClick={handleHeaderCreateSession} + command={displayMode === 'agent' ? undefined : 'topic.create'} + aria-label={headerCreateLabel} + disabled={headerCreateDisabled} + icon={displayMode === 'agent' ? : } + label={headerCreateLabel} + onClick={handleHeaderCreate} actions={ void setSessionDisplayMode(nextMode)} + onManageAgents={manageAgentsMenuItem?.onSelect} + onManageSkills={manageSkillsMenuItem?.onSelect} onOpenHistoryRecords={onOpenHistoryRecords} sectionId={ displayMode === 'agent' @@ -1538,7 +1481,6 @@ const Sessions = ({ /> } /> - )} @@ -1555,8 +1497,9 @@ const Sessions = ({ onOpenInNewTab={openSessionInNewTab} onOpenInNewWindow={openSessionInNewWindow} onRetry={handleRetry} - onSelectItem={onSelectItem} + onSetPanePosition={canSetPanePosition ? setResolvedPanePosition : undefined} onTogglePin={togglePin} + panePosition={canSetPanePosition ? resolvedPanePosition : undefined} setActiveSessionId={handleSelectSession} /> {!listLoading && (isLoadingMore || hasMore) && ( @@ -1595,8 +1538,9 @@ interface SessionListBodyProps { onOpenInNewTab?: (session: AgentSessionEntity) => void onOpenInNewWindow?: (session: AgentSessionEntity) => void onRetry: () => Promise - onSelectItem?: () => void + onSetPanePosition?: (position: TopicTabPosition) => void | Promise onTogglePin: (id: string) => void | Promise + panePosition?: TopicTabPosition setActiveSessionId: (id: string | null) => void } @@ -1613,8 +1557,9 @@ function SessionListBody({ onOpenInNewTab, onOpenInNewWindow, onRetry, - onSelectItem, + onSetPanePosition, onTogglePin, + panePosition, setActiveSessionId }: SessionListBodyProps) { const { t } = useTranslation() @@ -1634,8 +1579,9 @@ function SessionListBody({ onDelete={onDeleteSession} onOpenInNewTab={onOpenInNewTab} onOpenInNewWindow={onOpenInNewWindow} + onSetPanePosition={onSetPanePosition} + panePosition={panePosition} onPress={setActiveSessionId} - onSelectItem={onSelectItem} /> ), [ @@ -1645,8 +1591,9 @@ function SessionListBody({ onDeleteSession, onOpenInNewTab, onOpenInNewWindow, - onSelectItem, + onSetPanePosition, onTogglePin, + panePosition, setActiveSessionId ] ) diff --git a/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx b/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx index bce802193e7..93c9d896763 100644 --- a/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx +++ b/src/renderer/pages/agents/components/__tests__/Sessions.test.tsx @@ -99,10 +99,29 @@ vi.mock('@cherrystudio/ui', async (importOriginal) => { ), DropdownMenuSeparator: (props: any) =>
, + DropdownMenuSub: ({ children }: { children?: ReactNode }) =>
{children}
, + DropdownMenuSubContent: ({ children, ...props }: { children?: ReactNode }) =>
{children}
, + DropdownMenuSubTrigger: ({ children, disabled, ...props }: any) => ( + + ), DropdownMenuTrigger: ({ children }: { children?: ReactNode }) => <>{children} } }) +vi.mock('@renderer/components/Avatar/ModelAvatar', () => ({ + default: ({ model, size }: { model: { id: string; providerId: string }; size: number }) => ( + + ) +})) + beforeAll(() => { HTMLElement.prototype.scrollIntoView = () => {} }) @@ -244,8 +263,8 @@ const tabsContextMocks = vi.hoisted(() => ({ })) const dataApiMocks = vi.hoisted(() => ({ + deleteAgent: vi.fn().mockResolvedValue(undefined), deleteWorkspace: vi.fn().mockResolvedValue(undefined), - deleteAgentSessions: vi.fn().mockResolvedValue({ deletedIds: [] as string[], deletedCount: 0 }), findOrCreateWorkspace: vi.fn(async ({ body }: { body: { path: string } }) => { const workspace = dataApiMocks.workspaces.find((candidate) => candidate.path === body.path) return workspace ?? { id: 'ws-test', name: 'Test Workspace', path: body.path } @@ -393,8 +412,8 @@ vi.mock('@renderer/data/hooks/useDataApi', () => ({ ? dataApiMocks.updateWorkspace : method === 'DELETE' && path === '/agent-workspaces/:workspaceId' ? dataApiMocks.deleteWorkspace - : method === 'DELETE' && path === '/agents/:agentId/sessions' - ? dataApiMocks.deleteAgentSessions + : method === 'DELETE' && path === '/agents/:agentId' + ? dataApiMocks.deleteAgent : dataApiMocks.findOrCreateWorkspace, isLoading: false, error: undefined @@ -420,23 +439,24 @@ vi.mock('react-i18next', () => ({ t: (key: string, options?: Record) => { const labels: Record = { 'agent.session.add.title': 'Add task', + 'agent.add.title': 'Add Agent', 'agent.session.display.agent': 'Agent', 'agent.session.display.time': 'Time', 'agent.session.display.title': 'Display mode', '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.delete.content': 'Delete this agent and its tasks?', + 'agent.delete.error.failed': 'Failed to delete agent', + 'agent.delete.title': 'Delete Agent', 'agent.edit.title': 'Edit Agent', + 'agent.icon.type': 'Agent icon', 'agent.session.edit.title': 'Edit task', 'agent.session.file_manager.file_explorer': 'File Explorer', 'agent.session.file_manager.files': 'Files', 'agent.session.file_manager.finder': 'Finder', 'agent.session.get.error.failed': 'Failed to get tasks', - 'agent.session.agent.delete.content': - "Deleting this agent's tasks will delete all tasks associated with this agent. The agent itself will not be deleted.", - 'agent.session.agent.delete.error.failed': 'Failed to delete agent tasks', - 'agent.session.agent.delete.title': 'Delete agent tasks', - 'agent.session.agent.delete.trigger': 'Delete agent tasks', 'agent.session.group.collapse': 'Collapse display', 'agent.session.group.collapse_all': 'Collapse all', 'agent.session.group.conversation': 'Conversations', @@ -451,6 +471,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', @@ -484,6 +505,12 @@ vi.mock('react-i18next', () => ({ 'common.saved': 'Saved', 'common.unnamed': 'Untitled', 'error.model.not_exists': 'Model does not exist', + 'settings.agent.position.label': 'Session position', + 'settings.agent.position.left': 'Left', + 'settings.agent.position.right': 'Right', + 'settings.assistant.icon.type.emoji': 'Emoji', + 'settings.assistant.icon.type.model': 'Model', + 'settings.assistant.icon.type.none': 'None', 'selector.agent.create_new': 'Create agent', 'selector.agent.empty_text': 'No agents', 'selector.agent.search_placeholder': 'Search agents', @@ -532,8 +559,8 @@ function getHeaderNewTaskButton() { type SessionGroupCollapseFixture = { time: string[] - agent: string[] - workdir: string[] + agent: string[] | null + workdir: string[] | null } // Default fixture: nothing collapsed (everything expanded). @@ -663,6 +690,8 @@ describe('Sessions', () => { preferenceMocks.values.clear() cacheMocks.values.clear() preferenceMocks.values.set('agent.session.display_mode', 'workdir') + preferenceMocks.values.set('agent.icon_type', 'emoji') + preferenceMocks.values.set('agent.session.position', 'left') setSessionGroupExpansionCache(createExpandedSessionGroupExpansionFixture()) preferenceMocks.values.set('topic.tab.show', true) dataApiMocks.workspaces = [ @@ -672,8 +701,8 @@ describe('Sessions', () => { dataApiMocks.workspacesError = undefined dataApiMocks.workspacesLoading = false dataApiMocks.workspacesRefreshing = false + dataApiMocks.deleteAgent.mockResolvedValue(undefined) dataApiMocks.deleteWorkspace.mockResolvedValue(undefined) - dataApiMocks.deleteAgentSessions.mockResolvedValue({ deletedIds: [], deletedCount: 0 }) dataApiMocks.refetchAgents.mockResolvedValue(undefined) dataApiMocks.reorderAgent.mockResolvedValue(undefined) dataApiMocks.updateWorkspace.mockResolvedValue(undefined) @@ -761,6 +790,18 @@ describe('Sessions', () => { ).toBeInTheDocument() }) + it('defaults workspace display groups to collapsed before the user changes expansion', () => { + setSessionGroupExpansionCache({ + ...createExpandedSessionGroupExpansionFixture(), + workdir: null + }) + + render() + + expect(screen.getByRole('button', { name: 'Project A Workspace' })).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('Alpha session')).not.toBeInTheDocument() + }) + it('keeps the header new task action enabled without agents and starts a missing-agent draft', () => { const onStartDraftSession = vi.fn() const onStartMissingAgentDraft = vi.fn() @@ -811,6 +852,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() @@ -843,6 +908,20 @@ describe('Sessions', () => { expect(onStartDraftSession).not.toHaveBeenCalled() }) + it('uses the top header action to add an agent in agent display mode', () => { + const onAddAgent = vi.fn() + const onStartDraftSession = vi.fn() + preferenceMocks.values.set('agent.session.display_mode', 'agent') + setupSessions() + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Add Agent' })) + + expect(onAddAgent).toHaveBeenCalledTimes(1) + expect(onStartDraftSession).not.toHaveBeenCalled() + }) + it('renders no-project sessions in a bottom no-project section', () => { const onStartDraftSession = vi.fn() const systemWorkspace = makeWorkspace('/Users/jd/Data/Agents/system/2026-05-25/120000-session', { @@ -986,6 +1065,67 @@ describe('Sessions', () => { expect(getSessionGroupExpansionCache().agent).not.toContain('session:agent:agent-b') }) + it('defaults agent display groups to collapsed before the user changes expansion', () => { + preferenceMocks.values.set('agent.session.display_mode', 'agent') + setSessionGroupExpansionCache({ + ...createExpandedSessionGroupExpansionFixture(), + agent: null + }) + agentDataMocks.useAgents.mockReturnValue({ + agents: [ + { id: 'agent-a', model: 'model-a', name: 'Alpha agent', configuration: { avatar: 'A' } }, + { id: 'agent-b', model: 'model-b', name: 'Beta agent', configuration: { avatar: 'B' } } + ], + isLoading: false, + error: undefined + }) + setupSessions({ + sessions: [ + createSession({ id: 'session-a', name: 'Alpha session', agentId: 'agent-a', orderKey: 'a' }), + createSession({ id: 'session-b', name: 'Beta session', agentId: 'agent-b', orderKey: 'b' }) + ] + }) + + render() + + expect(screen.getByRole('button', { name: 'Alpha agent' })).toHaveAttribute('aria-expanded', 'false') + expect(screen.getByRole('button', { name: 'Beta agent' })).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('Alpha session')).not.toBeInTheDocument() + expect(screen.queryByText('Beta session')).not.toBeInTheDocument() + }) + + it('uses the configured model icon for agent session groups', () => { + preferenceMocks.values.set('agent.session.display_mode', 'agent') + preferenceMocks.values.set('agent.icon_type', 'model') + preferenceMocks.values.set('chat.default_model_id', 'provider-default::default-model') + setSessionGroupExpansionCache({ + ...createExpandedSessionGroupExpansionFixture(), + agent: ['session:agent:agent-a'] + }) + agentDataMocks.useAgents.mockReturnValue({ + agents: [ + { + id: 'agent-a', + model: 'provider-a::model-a', + modelName: 'Model A', + name: 'Alpha agent', + configuration: { avatar: 'A' } + } + ], + isLoading: false, + error: undefined + }) + setupSessions({ + sessions: [createSession({ id: 'session-a', name: 'Alpha session', agentId: 'agent-a', orderKey: 'a' })] + }) + + render() + + const agentHeader = screen.getByRole('button', { name: 'Alpha agent' }).closest('div') + expect(agentHeader).toBeInTheDocument() + expect(within(agentHeader as HTMLElement).getByTestId('model-avatar')).toHaveAttribute('data-model-id', 'model-a') + }) + it('uses the provided active session setter', () => { preferenceMocks.values.set('agent.session.display_mode', 'agent') setSessionGroupExpansionCache({ @@ -1118,6 +1258,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' })] }) @@ -1127,18 +1268,54 @@ 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') }) + 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') @@ -1458,6 +1635,60 @@ describe('Sessions', () => { requestAnimationFrameSpy.mockRestore() }) + it('changes topic position from the session context menu', async () => { + preferenceMocks.values.set('agent.session.display_mode', 'agent') + render() + + fireEvent.contextMenu(screen.getByText('Alpha session')) + const alphaMenu = screen.getByText('Alpha session').closest('[data-testid="context-menu"]') + const menuContent = alphaMenu?.querySelector('[data-testid="context-menu-content"]') + + expect(menuContent).toHaveTextContent('Session position') + + fireEvent.click(within(menuContent as HTMLElement).getByText('Right')) + + await vi.waitFor(() => { + expect(preferenceMocks.setPreference).toHaveBeenCalledWith('agent.session.position', 'right') + }) + }) + + it('hides topic position actions from the workdir-mode session context menu', () => { + preferenceMocks.values.set('agent.session.display_mode', 'workdir') + render() + + fireEvent.contextMenu(screen.getByText('Alpha session')) + const alphaMenu = screen.getByText('Alpha session').closest('[data-testid="context-menu"]') + const menuContent = alphaMenu?.querySelector('[data-testid="context-menu-content"]') + + expect(menuContent ?? null).toBeInTheDocument() + expect(menuContent).not.toHaveTextContent('Conversation position') + }) + + it('changes the right-panel session list to the left side from the context menu', async () => { + const onSetPanePosition = vi.fn() + render( + + ) + + fireEvent.contextMenu(screen.getByText('Alpha session')) + const alphaMenu = screen.getByText('Alpha session').closest('[data-testid="context-menu"]') + const menuContent = alphaMenu?.querySelector('[data-testid="context-menu-content"]') + + expect(menuContent ?? null).toBeInTheDocument() + const leftAction = within(menuContent as HTMLElement).getByRole('menuitem', { name: 'Left' }) + expect(leftAction).not.toBeDisabled() + fireEvent.click(leftAction) + + await vi.waitFor(() => { + expect(onSetPanePosition).toHaveBeenCalledWith('left') + }) + }) + it('hides open-in-new-tab for the active session context menu', () => { render() @@ -1556,6 +1787,42 @@ describe('Sessions', () => { expect(setActiveSessionId).not.toHaveBeenCalledWith('session-a2-first', expect.anything()) }) + it('selects the display-order neighbour (not the raw API head) after deleting the active sidebar session', async () => { + preferenceMocks.values.set('agent.session.display_mode', 'agent') + agentDataMocks.useAgents.mockReturnValue({ + agents: [{ id: 'agent-a', model: 'model-a', name: 'Alpha agent', configuration: { avatar: 'A' } }], + isLoading: false, + error: undefined + }) + setupSessions({ + sessions: [ + createSession({ id: 'session-a', name: 'A session', agentId: 'agent-a', orderKey: 'a' }), + createSession({ id: 'session-b', name: 'B session', agentId: 'agent-a', orderKey: 'b' }), + createSession({ id: 'session-c', name: 'C session', agentId: 'agent-a', orderKey: 'c' }) + ] + }) + const setActiveSessionId = vi.fn() + + // Modern sidebar (default presentation), deleting the middle session in display order. + render() + + const sessionRow = screen.getByText('B session').closest('[role="option"]') + const deleteButton = within(sessionRow as HTMLElement).getByLabelText('Delete') + act(() => { + fireEvent.click(deleteButton) + }) + act(() => { + fireEvent.click(deleteButton) + }) + + await vi.waitFor(() => expect(sessionDataMocks.deleteSession).toHaveBeenCalledWith('session-b')) + // Neighbour in the visible display order, not the raw API/orderKey head (session-a). + await vi.waitFor(() => + expect(setActiveSessionId).toHaveBeenCalledWith('session-c', expect.objectContaining({ id: 'session-c' })) + ) + expect(setActiveSessionId).not.toHaveBeenCalledWith('session-a', expect.anything()) + }) + it('starts an agent-scoped draft after deleting the active agent last session in the right panel', async () => { agentDataMocks.useAgents.mockReturnValue({ agents: [ @@ -1698,15 +1965,19 @@ describe('Sessions', () => { expect(screen.getByTestId('agent-session-stream-indicator').firstElementChild).toHaveClass('animation-pulse') }) - it('persists display mode selection from the header menu', () => { + it('persists display mode selection from the header menu', async () => { render() 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') + await vi.waitFor(() => { + expect(preferenceMocks.setPreference).toHaveBeenCalledWith('agent.session.display_mode', 'agent') + }) }) it('blocks cross-workspace groups from drag start while preserving same-workspace reorder', async () => { @@ -2181,9 +2452,23 @@ describe('Sessions', () => { await vi.waitFor(() => expect(toggleAgentPin).toHaveBeenCalledWith('agent-a')) await vi.waitFor(() => expect(dataApiMocks.refetchAgents).toHaveBeenCalled()) + + fireEvent.pointerDown(moreButton) + const iconMenuItem = screen + .getAllByRole('menuitem', { name: 'Agent icon' }) + .find((button) => button.getAttribute('data-slot') === 'dropdown-menu-sub-trigger') + expect(iconMenuItem).toBeDefined() + const modelIconMenuItem = screen + .getAllByRole('menuitem', { name: 'Model' }) + .find((button) => button.getAttribute('data-slot') === 'dropdown-menu-item') + expect(modelIconMenuItem).toBeDefined() + fireEvent.click(modelIconMenuItem as HTMLElement) + + await vi.waitFor(() => expect(preferenceMocks.setPreference).toHaveBeenCalledWith('agent.icon_type', 'model')) }) - it('deletes agent group sessions through the agent-scoped batch endpoint', async () => { + it('deletes an agent from the agent group menu', async () => { + const onActiveAgentDeleted = vi.fn() preferenceMocks.values.set('agent.session.display_mode', 'agent') agentDataMocks.useAgents.mockReturnValue({ agents: [ @@ -2200,96 +2485,44 @@ describe('Sessions', () => { createSession({ id: 'session-b', name: 'Beta session', agentId: 'agent-b', orderKey: 'b' }) ] }) - dataApiMocks.deleteAgentSessions.mockResolvedValueOnce({ deletedIds: ['session-a'], deletedCount: 1 }) - render() + render() const agentGroup = screen.getByRole('button', { name: 'Alpha agent' }).closest('div') expect(agentGroup).not.toBeNull() fireEvent.pointerDown(within(agentGroup as HTMLElement).getByRole('button', { name: 'More' })) - const deleteMenuItem = screen - .getAllByRole('menuitem', { name: 'Delete agent tasks' }) + expect(screen.queryByRole('menuitem', { name: 'Delete agent tasks' })).not.toBeInTheDocument() + const deleteAgentMenuItem = screen + .getAllByRole('menuitem', { name: 'Delete Agent' }) .find((button) => button.getAttribute('data-slot') === 'dropdown-menu-item') - expect(deleteMenuItem).toBeDefined() - expect(deleteMenuItem?.querySelector('svg')).toHaveClass('lucide-custom', 'text-destructive') - fireEvent.click(deleteMenuItem as HTMLElement) + expect(deleteAgentMenuItem).toBeDefined() + expect(deleteAgentMenuItem?.querySelector('svg')).toHaveClass('lucide-custom', 'text-destructive') + + fireEvent.click(deleteAgentMenuItem as HTMLElement) await vi.waitFor(() => - expect(dataApiMocks.deleteAgentSessions).toHaveBeenCalledWith({ - params: { agentId: 'agent-a' } + expect(dataApiMocks.deleteAgent).toHaveBeenCalledWith({ + params: { agentId: 'agent-a' }, + query: { deleteSessions: true } }) ) expect(window.modal.confirm).toHaveBeenCalledWith( expect.objectContaining({ - content: - "Deleting this agent's tasks will delete all tasks associated with this agent. The agent itself will not be deleted.", - title: 'Delete agent tasks' + content: 'Delete this agent and its tasks?', + title: 'Delete Agent' }) ) - expect(dataApiMocks.mutationOptions.get('DELETE /agents/:agentId/sessions')?.refresh).toEqual([ + expect(onActiveAgentDeleted).toHaveBeenCalledWith('agent-a') + expect(dataApiMocks.mutationOptions.get('DELETE /agents/:agentId')?.refresh).toEqual([ + '/agents', '/agent-sessions', '/agent-workspaces', '/pins', '/agent-channels' ]) - expect(sessionDataMocks.deleteSession).not.toHaveBeenCalled() - expect(cacheMocks.setActiveSessionId).toHaveBeenCalledWith( - 'session-b', - expect.objectContaining({ id: 'session-b' }) - ) + await vi.waitFor(() => expect(dataApiMocks.refetchAgents).toHaveBeenCalled()) await vi.waitFor(() => expect(sessionDataMocks.reload).toHaveBeenCalled()) - }) - - it('blocks concurrent agent group delete confirmations', async () => { - let resolveConfirm!: (value: boolean) => void - const confirmPromise = new Promise((resolve) => { - resolveConfirm = resolve - }) - const confirm = vi.fn().mockReturnValue(confirmPromise) - Object.assign(window, { modal: { confirm } }) - preferenceMocks.values.set('agent.session.display_mode', 'agent') - agentDataMocks.useAgents.mockReturnValue({ - agents: [ - { id: 'agent-a', model: 'model-a', name: 'Alpha agent' }, - { id: 'agent-b', model: 'model-b', name: 'Beta agent' } - ], - isLoading: false, - error: undefined, - refetch: dataApiMocks.refetchAgents - }) - setupSessions({ - sessions: [ - createSession({ id: 'session-a', name: 'Alpha session', agentId: 'agent-a', orderKey: 'a' }), - createSession({ id: 'session-b', name: 'Beta session', agentId: 'agent-b', orderKey: 'b' }) - ] - }) - dataApiMocks.deleteAgentSessions.mockResolvedValueOnce({ deletedIds: ['session-a'], deletedCount: 1 }) - - render() - - const alphaGroup = screen.getByRole('button', { name: 'Alpha agent' }).closest('div') - const betaGroup = screen.getByRole('button', { name: 'Beta agent' }).closest('div') - expect(alphaGroup).not.toBeNull() - expect(betaGroup).not.toBeNull() - fireEvent.pointerDown(within(alphaGroup as HTMLElement).getByRole('button', { name: 'More' })) - fireEvent.click(within(alphaGroup as HTMLElement).getByRole('menuitem', { name: 'Delete agent tasks' })) - - await vi.waitFor(() => expect(confirm).toHaveBeenCalledTimes(1)) - fireEvent.pointerDown(within(betaGroup as HTMLElement).getByRole('button', { name: 'More' })) - const betaDeleteMenuItem = within(betaGroup as HTMLElement).getByRole('menuitem', { name: 'Delete agent tasks' }) - await vi.waitFor(() => expect(betaDeleteMenuItem).toBeDisabled()) - fireEvent.click(betaDeleteMenuItem) - - expect(confirm).toHaveBeenCalledTimes(1) - expect(dataApiMocks.deleteAgentSessions).not.toHaveBeenCalled() - - await act(async () => { - resolveConfirm(true) - await confirmPromise - }) - - await vi.waitFor(() => expect(dataApiMocks.deleteAgentSessions).toHaveBeenCalledTimes(1)) - expect(dataApiMocks.deleteAgentSessions).toHaveBeenCalledWith({ params: { agentId: 'agent-a' } }) + expect(window.toast.success).toHaveBeenCalledWith('Deleted successfully') }) it('collapses agent groups from the display options menu', async () => { diff --git a/src/renderer/pages/agents/components/__tests__/sessionItemActions.test.tsx b/src/renderer/pages/agents/components/__tests__/sessionItemActions.test.tsx index 75bcf670d52..025dce1e490 100644 --- a/src/renderer/pages/agents/components/__tests__/sessionItemActions.test.tsx +++ b/src/renderer/pages/agents/components/__tests__/sessionItemActions.test.tsx @@ -8,6 +8,8 @@ function createSessionActionFixture(overrides: Partial = { return { isActiveInCurrentTab: false, onDelete: vi.fn(), + onSetPanePosition: vi.fn(), + panePosition: 'left', pinned: false, sessionName: 'Session title', startEdit: vi.fn(), @@ -20,7 +22,7 @@ describe('session item actions', () => { it('resolves rename and delete actions without pin when pin callback is absent', () => { const actions = resolveSessionMenuActions(createSessionActionFixture()) - expect(actions.map((action) => action.id)).toEqual(['session.rename', 'session.delete']) + expect(actions.map((action) => action.id)).toEqual(['session.rename', 'session.position', 'session.delete']) }) it('resolves pin label from pinned state and executes callbacks without agent editing', async () => { @@ -33,7 +35,7 @@ describe('session item actions', () => { }) const actions = resolveSessionMenuActions(actionContext) - expect(actions.map((action) => action.id)).toEqual(['session.rename', 'session.toggle-pin']) + expect(actions.map((action) => action.id)).toEqual(['session.rename', 'session.toggle-pin', 'session.position']) expect(actions.find((action) => action.id === 'session.toggle-pin')?.label).toBe('agent.session.unpin.title') await executeSessionMenuAction(actions[0], actionContext) @@ -51,7 +53,7 @@ describe('session item actions', () => { }) ) - expect(actions.map((action) => action.id)).toEqual(['session.rename', 'session.delete']) + expect(actions.map((action) => action.id)).toEqual(['session.rename', 'session.position', 'session.delete']) }) it('keeps open-in-new-window available even when the session is active in the current tab', async () => { @@ -65,6 +67,7 @@ describe('session item actions', () => { expect(actions.map((action) => action.id)).toEqual([ 'session.rename', 'session.open-in-new-window', + 'session.position', 'session.delete' ]) @@ -73,6 +76,35 @@ describe('session item actions', () => { expect(onOpenInNewWindow).toHaveBeenCalled() }) + it('sets the pane position from the position submenu', async () => { + const onSetPanePosition = vi.fn() + const actionContext = createSessionActionFixture({ onSetPanePosition }) + const actions = resolveSessionMenuActions(actionContext) + const positionAction = actions.find((action) => action.id === 'session.position') + const rightAction = positionAction?.children.find((action) => action.id === 'session.position-right') + + expect(positionAction?.label).toBe('settings.agent.position.label') + expect(rightAction?.availability.enabled).toBe(true) + + await executeSessionMenuAction(rightAction as (typeof actions)[number], actionContext) + + expect(onSetPanePosition).toHaveBeenCalledWith('right') + }) + + it('sets the pane position back to left from the right pane state', async () => { + const onSetPanePosition = vi.fn() + const actionContext = createSessionActionFixture({ onSetPanePosition, panePosition: 'right' }) + const actions = resolveSessionMenuActions(actionContext) + const positionAction = actions.find((action) => action.id === 'session.position') + const leftAction = positionAction?.children.find((action) => action.id === 'session.position-left') + + expect(leftAction?.availability.enabled).toBe(true) + + await executeSessionMenuAction(leftAction as (typeof actions)[number], actionContext) + + expect(onSetPanePosition).toHaveBeenCalledWith('left') + }) + it('uses localized cancel text for the delete confirmation', () => { const actions = resolveSessionMenuActions(createSessionActionFixture()) const deleteAction = actions.find((action) => action.id === 'session.delete') diff --git a/src/renderer/pages/agents/components/agentGroupActions.tsx b/src/renderer/pages/agents/components/agentGroupActions.tsx index ce128137b2d..c19d6ea292a 100644 --- a/src/renderer/pages/agents/components/agentGroupActions.tsx +++ b/src/renderer/pages/agents/components/agentGroupActions.tsx @@ -1,13 +1,22 @@ import { createActionRegistry } from '@renderer/components/chat/actions/actionRegistry' import type { ResolvedAction } from '@renderer/components/chat/actions/actionTypes' +import { + buildIconTypeActionDescriptors, + buildResourceEntityIconTypeActionDescriptor, + buildResourceEntityMenuActionDescriptor, + RESOURCE_ICON_TYPE_OPTIONS +} from '@renderer/components/chat/resourceList/base' +import type { AssistantIconType } from '@shared/data/preference/preferenceTypes' import type { TFunction } from 'i18next' -import { Pin, PinOff, SquarePen, Trash2 } from 'lucide-react' +import { Pin, PinOff, Smile, SquarePen, Trash2 } from 'lucide-react' export interface AgentGroupActionContext { agentId: string - deleteSessionsDisabled?: boolean + assistantIconType: AssistantIconType + deleteAgentDisabled?: boolean onEdit: (agentId: string) => void - onDeleteSessions: (agentId: string) => void | Promise + onDeleteAgent: (agentId: string) => void | Promise + onSetAgentIconType: (iconType: AssistantIconType) => void | Promise onTogglePin: (agentId: string) => void | Promise pinDisabled?: boolean pinned: boolean @@ -31,40 +40,60 @@ agentGroupActionRegistry.registerCommand({ run: ({ agentId, onTogglePin }) => onTogglePin(agentId) }) +for (const type of RESOURCE_ICON_TYPE_OPTIONS) { + agentGroupActionRegistry.registerCommand({ + id: `agent-group.set-icon-type.${type}`, + run: ({ onSetAgentIconType }) => onSetAgentIconType(type) + }) +} + agentGroupActionRegistry.registerCommand({ - id: 'agent-group.delete-sessions', - availability: ({ deleteSessionsDisabled }) => ({ enabled: !deleteSessionsDisabled }), - run: ({ agentId, onDeleteSessions }) => onDeleteSessions(agentId) + id: 'agent-group.delete-agent', + availability: ({ deleteAgentDisabled }) => ({ enabled: !deleteAgentDisabled }), + run: ({ agentId, onDeleteAgent }) => onDeleteAgent(agentId) }) -agentGroupActionRegistry.registerAction({ - id: 'agent-group.edit', - commandId: 'agent-group.edit', - label: ({ t }) => t('agent.edit.title'), - icon: () => , - order: 10, - surface: 'menu' -}) +agentGroupActionRegistry.registerAction( + buildResourceEntityMenuActionDescriptor({ + id: 'agent-group.edit', + commandId: 'agent-group.edit', + label: ({ t }) => t('agent.edit.title'), + icon: () => , + order: 10 + }) +) -agentGroupActionRegistry.registerAction({ - id: 'agent-group.toggle-pin', - commandId: 'agent-group.toggle-pin', - label: ({ pinned, t }) => (pinned ? t('agent.unpin.title') : t('agent.pin.title')), - icon: ({ pinned }) => (pinned ? : ), - order: 20, - surface: 'menu' -}) +agentGroupActionRegistry.registerAction( + buildResourceEntityMenuActionDescriptor({ + id: 'agent-group.toggle-pin', + commandId: 'agent-group.toggle-pin', + label: ({ pinned, t }) => (pinned ? t('agent.unpin.title') : t('agent.pin.title')), + icon: ({ pinned }) => (pinned ? : ), + order: 20 + }) +) -agentGroupActionRegistry.registerAction({ - id: 'agent-group.delete-sessions', - commandId: 'agent-group.delete-sessions', - label: ({ t }) => t('agent.session.agent.delete.trigger'), - icon: () => , - group: 'danger', - order: 30, - surface: 'menu', - danger: true -}) +agentGroupActionRegistry.registerAction( + buildResourceEntityIconTypeActionDescriptor({ + id: 'agent-group.icon-type', + label: ({ t }) => t('agent.icon.type'), + icon: () => , + order: 30, + children: buildIconTypeActionDescriptors('agent-group.set-icon-type') + }) +) + +agentGroupActionRegistry.registerAction( + buildResourceEntityMenuActionDescriptor({ + id: 'agent-group.delete-agent', + commandId: 'agent-group.delete-agent', + label: ({ t }) => t('agent.delete.title'), + icon: () => , + group: 'danger', + order: 40, + danger: true + }) +) export function resolveAgentGroupActions(context: AgentGroupActionContext): AgentGroupAction[] { return agentGroupActionRegistry.resolve(context, 'menu') diff --git a/src/renderer/pages/agents/components/sessionItemActions.tsx b/src/renderer/pages/agents/components/sessionItemActions.tsx index a44f919efb3..55aa0780783 100644 --- a/src/renderer/pages/agents/components/sessionItemActions.tsx +++ b/src/renderer/pages/agents/components/sessionItemActions.tsx @@ -3,15 +3,18 @@ import type { ResolvedAction } from '@renderer/components/chat/actions/actionTyp import DeleteIcon from '@renderer/components/icons/DeleteIcon' import EditIcon from '@renderer/components/icons/EditIcon' import { OpenInNewWindowIcon } from '@renderer/components/icons/WindowIcons' +import type { TopicTabPosition } from '@shared/data/preference/preferenceTypes' import type { TFunction } from 'i18next' -import { ExternalLink, PinIcon, PinOffIcon } from 'lucide-react' +import { ExternalLink, PanelLeft, PinIcon, PinOffIcon } from 'lucide-react' export interface SessionActionContext { isActiveInCurrentTab: boolean onDelete: () => void onOpenInNewTab?: () => void onOpenInNewWindow?: () => void + onSetPanePosition?: (position: TopicTabPosition) => void | Promise onTogglePin?: () => void + panePosition?: TopicTabPosition pinned?: boolean sessionName: string startEdit: (value: string) => void @@ -49,6 +52,24 @@ sessionActionRegistry.registerCommand({ run: ({ onOpenInNewWindow }) => onOpenInNewWindow?.() }) +sessionActionRegistry.registerCommand({ + id: 'session.position-left', + availability: ({ onSetPanePosition, panePosition }) => ({ + visible: !!onSetPanePosition && !!panePosition, + enabled: !!onSetPanePosition && panePosition !== 'left' + }), + run: ({ onSetPanePosition }) => onSetPanePosition?.('left') +}) + +sessionActionRegistry.registerCommand({ + id: 'session.position-right', + availability: ({ onSetPanePosition, panePosition }) => ({ + visible: !!onSetPanePosition && !!panePosition, + enabled: !!onSetPanePosition && panePosition !== 'right' + }), + run: ({ onSetPanePosition }) => onSetPanePosition?.('right') +}) + sessionActionRegistry.registerCommand({ id: 'session.delete', run: ({ onDelete }) => onDelete() @@ -90,6 +111,31 @@ sessionActionRegistry.registerAction({ surface: 'menu' }) +sessionActionRegistry.registerAction({ + id: 'session.position', + label: ({ t }) => t('settings.agent.position.label'), + icon: () => , + order: 36, + surface: 'menu', + availability: ({ onSetPanePosition, panePosition }) => ({ visible: !!onSetPanePosition && !!panePosition }), + children: [ + { + id: 'session.position-left', + commandId: 'session.position-left', + label: ({ t }) => t('settings.agent.position.left'), + order: 10, + surface: 'menu' + }, + { + id: 'session.position-right', + commandId: 'session.position-right', + label: ({ t }) => t('settings.agent.position.right'), + order: 20, + surface: 'menu' + } + ] +}) + sessionActionRegistry.registerAction({ id: 'session.delete', commandId: 'session.delete', diff --git a/src/renderer/pages/home/HomePage.tsx b/src/renderer/pages/home/HomePage.tsx index 9bf31c69531..91ba7c8e07a 100644 --- a/src/renderer/pages/home/HomePage.tsx +++ b/src/renderer/pages/home/HomePage.tsx @@ -64,6 +64,7 @@ import ChatNavbar from './components/ChatNavbar' import { TopicRightPane } from './components/TopicRightPane' import { parseChatRouteSearch } from './routeSearch' import { Topics } from './Tabs/components/Topics' +import { getTopicAssistantDisplayGroupId } from './Tabs/components/topicsHelpers' import HomeTabs from './Tabs/HomeTabs' import type { AddNewTopicPayload } from './types' @@ -80,6 +81,9 @@ type DraftAssistantStartState = { type DraftAssistantSelection = { assistantId?: string } +type DraftAssistantTargetOptions = { + excludedAssistantIds?: readonly string[] +} // Reuse the assistant's latest *empty* placeholder topic instead of stacking a new one. The empty // topic only exists to surface the assistant in the classic-layout rail, so on repeated adds we reopen the @@ -119,13 +123,16 @@ const HomePage: FC = () => { const [lastUsedAssistantId, setLastUsedAssistantId] = usePersistCache(LAST_USED_ASSISTANT_CACHE_KEY) const [, setLastUsedTopicId] = usePersistCache('ui.chat.last_used_topic_id') const [, setRecentItems] = usePersistCache('ui.global_search.recent_items') + const [, setTopicExpansionAssistant] = usePersistCache('ui.topic.expansion.assistant') 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, setTopicDisplayMode] = usePreference('topic.tab.display_mode') + const [panePosition, setPanePosition] = usePreference('topic.tab.position') + 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 { @@ -138,6 +145,17 @@ const HomePage: FC = () => { const [historyRecordsOpen, setHistoryRecordsOpen] = useState(false) const [assistantPickerOpen, setAssistantPickerOpen] = useState(false) + useEffect(() => { + if (!isClassicTopicLayout || panePosition !== 'right') { + hasAutoOpenedClassicTopicPaneRef.current = false + return + } + + if (hasAutoOpenedClassicTopicPaneRef.current) return + hasAutoOpenedClassicTopicPaneRef.current = true + setTopicPaneOpen(true) + }, [isClassicTopicLayout, panePosition, setTopicPaneOpen]) + const location = useLocation() const routeSearch = parseChatRouteSearch(useSearch({ strict: false }) as Record) const currentTab = useCurrentTab() @@ -175,25 +193,32 @@ const HomePage: FC = () => { const assistantIdSet = useMemo(() => new Set(assistants.map((assistant) => assistant.id)), [assistants]) const validLastUsedAssistantId = lastUsedAssistantId && assistantIdSet.has(lastUsedAssistantId) ? lastUsedAssistantId : undefined - const fallbackAssistantId = assistants[0]?.id const isAssistantListResolved = hasAssistantsLoaded && !isAssistantsLoading && !isAssistantsRefreshing const resolveDraftAssistantTarget = useCallback( - (explicitAssistantId?: string | null): ResolvedDraftAssistantSelection => { + ( + explicitAssistantId?: string | null, + options: DraftAssistantTargetOptions = {} + ): ResolvedDraftAssistantSelection => { + const excludedAssistantIds = new Set(options.excludedAssistantIds ?? []) + const isAvailableAssistantId = (assistantId: string | null | undefined): assistantId is string => + !!assistantId && assistantIdSet.has(assistantId) && !excludedAssistantIds.has(assistantId) + if (explicitAssistantId === null) { return { source: 'explicit' } } - if (explicitAssistantId && assistantIdSet.has(explicitAssistantId)) { + if (isAvailableAssistantId(explicitAssistantId)) { return { assistantId: explicitAssistantId, source: 'explicit' } } - if (validLastUsedAssistantId) { + if (isAvailableAssistantId(validLastUsedAssistantId)) { return { assistantId: validLastUsedAssistantId, source: 'last-used' } } + const fallbackAssistantId = assistants.find((assistant) => !excludedAssistantIds.has(assistant.id))?.id if (fallbackAssistantId) { return { assistantId: fallbackAssistantId, source: 'first-assistant' } } return { source: 'runtime-fallback' } }, - [assistantIdSet, fallbackAssistantId, validLastUsedAssistantId] + [assistantIdSet, assistants, validLastUsedAssistantId] ) const initialTopic = useMemo(() => { @@ -331,13 +356,13 @@ const HomePage: FC = () => { const visibleAssistantId = visibleTopic?.assistantId ?? draftAssistantSelectionSnapshot?.assistantId const { assistant: visibleAssistant } = useAssistantApiById(visibleAssistantId ?? undefined) const topicResourcePaneCount = useMemo(() => { - if (!isClassicTopicLayout || !visibleAssistantId) return undefined + if (!isClassicTopicLayout || panePosition !== 'right' || !visibleAssistantId) return undefined return { label: t('chat.topics.title'), count: classicLayoutTopics.filter((topic) => topic.assistantId === visibleAssistantId).length } - }, [isClassicTopicLayout, classicLayoutTopics, t, visibleAssistantId]) + }, [isClassicTopicLayout, panePosition, classicLayoutTopics, t, visibleAssistantId]) const isDraftView = !isMessageOnlyView && !!draftAssistantSelectionSnapshot const tabInstanceTopicId = !isMessageOnlyView && !isDraftView ? (visibleTopic?.id ?? routeActiveTopicId ?? undefined) : undefined @@ -420,10 +445,10 @@ const HomePage: FC = () => { }, [isMessageOnlyView, setActiveTopic, setDraftAssistantSelectionState, state?.topic]) const startDraftAssistantSelection = useCallback( - (payload?: AddNewTopicPayload) => { + (payload?: AddNewTopicPayload, options?: DraftAssistantTargetOptions) => { try { closeResourceView() - const selection = resolveDraftAssistantTarget(payload?.assistantId) + const selection = resolveDraftAssistantTarget(payload?.assistantId, options) const targetAssistantId = selection.assistantId const current = draftAssistantSelectionRef.current @@ -511,15 +536,24 @@ const HomePage: FC = () => { const nextTopic = findLatestUpdated( classicLayoutTopics.filter((topic) => topic.assistantId !== deletedAssistantId) ) + if (lastUsedAssistantId === deletedAssistantId) { + setLastUsedAssistantId(null) + } // setActiveTopicAndDiscardDraft returns false when the next topic is already open in another // tab (it focuses that tab). In that case the current tab would otherwise keep pointing at the // just-deleted topic, so fall through to a draft instead of leaving a ghost. if (nextTopic && setActiveTopicAndDiscardDraft(mapApiTopicToRendererTopic(nextTopic))) { return } - startDraftAssistantSelection() + startDraftAssistantSelection(undefined, { excludedAssistantIds: [deletedAssistantId] }) }, - [classicLayoutTopics, setActiveTopicAndDiscardDraft, startDraftAssistantSelection] + [ + classicLayoutTopics, + lastUsedAssistantId, + setActiveTopicAndDiscardDraft, + setLastUsedAssistantId, + startDraftAssistantSelection + ] ) const resolveAssistantIdForSelection = useCallback( @@ -603,6 +637,29 @@ const HomePage: FC = () => { [createTopic, classicLayoutTopics, refreshTopics, resolveDraftAssistantTarget, setActiveTopicAndDiscardDraft, t] ) + const createAndActivateFreshTopic = useCallback( + async (payload: AddNewTopicPayload) => { + if (isCreatingTopicRef.current) return + isCreatingTopicRef.current = true + try { + const selection = resolveDraftAssistantTarget(payload.assistantId) + const topic = await createTopic({ + ...(selection.assistantId ? { assistantId: selection.assistantId } : {}) + }) + setActiveTopicAndDiscardDraft(mapApiTopicToRendererTopic(topic)) + void refreshTopics().catch((err) => { + logger.warn('Failed to refresh topics after fresh topic create', err as Error) + }) + } catch (err) { + logger.error('Failed to create fresh topic', err as Error) + window.toast.error(formatErrorMessageWithPrefix(err, t('common.error'))) + } finally { + isCreatingTopicRef.current = false + } + }, + [createTopic, refreshTopics, resolveDraftAssistantTarget, setActiveTopicAndDiscardDraft, t] + ) + // "去对话" from the assistant library (after adding a preset). The legacy navigate-to-chat no longer // fits the classic/modern split, so branch on layout: classic auto-creates an empty topic and // switches to it; modern drops into the draft compose with the assistant pre-selected. Both handlers @@ -716,6 +773,36 @@ const HomePage: FC = () => { toggleResourceListOpen ] ) + const setTopicListPosition = useCallback( + async (position: ChatPanePosition) => { + await setTopicDisplayMode('assistant') + if (position === 'left') { + const activeAssistantGroupId = visibleTopic ? getTopicAssistantDisplayGroupId(visibleTopic) : undefined + const collapsedAssistantGroupIds = Array.from( + new Set( + classicLayoutTopics + .map(getTopicAssistantDisplayGroupId) + .filter((groupId) => groupId !== activeAssistantGroupId) + ) + ) + setTopicExpansionAssistant(collapsedAssistantGroupIds) + } + await setPanePosition(position) + setTopicPaneOpen(position === 'right') + setResourceListOpen(true) + }, + [ + classicLayoutTopics, + setPanePosition, + setResourceListOpen, + setTopicDisplayMode, + setTopicExpansionAssistant, + setTopicPaneOpen, + visibleTopic + ] + ) + const topicListPosition: ChatPanePosition = isClassicTopicLayout && panePosition === 'right' ? 'right' : 'left' + const shellPanePosition: ChatPanePosition = 'left' if (!visibleTopic && !draftAssistantSelectionSnapshot && !resourceCenter) { if (isMessageOnlyView) { @@ -736,47 +823,60 @@ const HomePage: FC = () => { } // Classic layout = entity rail + right topic panel; modern layout = the single sidebar (HomeTabs). - const panePosition: ChatPanePosition = 'left' - const pane = isClassicTopicLayout ? ( - { - setAssistantPickerOpen(true) - }} - onOpenHistoryRecords={openHistoryRecords} - onSelectTopic={setActiveTopicAndDiscardDraft} - onStartDraftAssistant={(assistantId) => startDraftAssistantSelection({ assistantId })} - resourceMenuItems={resourceMenuItems} - onActiveAssistantDeleted={handleActiveAssistantDeleted} - /> - ) : ( - - ) + const pane = + isClassicTopicLayout && topicListPosition === 'right' ? ( + { + setAssistantPickerOpen(true) + }} + onOpenHistoryRecords={openHistoryRecords} + onSelectTopic={setActiveTopicAndDiscardDraft} + onCreateTopicAfterClear={(assistantId) => createAndActivateFreshTopic({ assistantId })} + onSelectedAssistantClick={() => setTopicPaneOpen(!topicPaneOpen)} + onStartDraftAssistant={(assistantId) => startDraftAssistantSelection({ assistantId })} + resourceMenuItems={resourceMenuItems} + onActiveAssistantDeleted={handleActiveAssistantDeleted} + /> + ) : ( + { + setAssistantPickerOpen(true) + }} + setActiveTopic={setActiveTopicAndDiscardDraft} + onCreateTopicAfterClear={isMessageOnlyView ? undefined : createAndActivateFreshTopic} + onNewTopic={isMessageOnlyView ? undefined : startDraftAssistantSelection} + onOpenHistoryRecords={openHistoryRecords} + revealRequest={topicRevealRequest} + resourceMenuItems={resourceMenuItems} + onSetPanePosition={setTopicListPosition} + panePosition="left" + /> + ) // In classic layout the topic list moves into the chat's right pane as a tab; the single page-level // provider owns the Shell for both views so the rail and the right panel share its open/maximize // state. New (sidebar) view passes a null config, leaving the pane as branch/trace only. - const resourcePane: ResourcePaneConfig | null = isClassicTopicLayout - ? { - label: t('chat.topics.title'), - node: ( - - ) - } - : null + const resourcePane: ResourcePaneConfig | null = + isClassicTopicLayout && topicListPosition === 'right' + ? { + label: t('chat.topics.title'), + node: ( + + ) + } + : null const renderWithRightPane = (content: ReactNode) => ( { center={resourceCenter} pane={pane} paneOpen={effectiveShowSidebar} - panePosition={panePosition} + panePosition={shellPanePosition} onPaneCollapse={() => setResourceListOpen(false)} /> @@ -833,7 +933,7 @@ const HomePage: FC = () => { scopeKey={draftScopeKey} pane={pane} paneOpen={effectiveShowSidebar} - panePosition={panePosition} + panePosition={shellPanePosition} onPaneCollapse={() => setResourceListOpen(false)} onNewTopic={isMessageOnlyView ? undefined : startDraftAssistantSelection} onCreateEmptyTopic={isClassicTopicLayout && !isMessageOnlyView ? createAndActivateEmptyTopic : undefined} @@ -862,7 +962,7 @@ const HomePage: FC = () => { activeTopic={chatTopic} pane={pane} paneOpen={effectiveShowSidebar} - panePosition={panePosition} + panePosition={shellPanePosition} onPaneCollapse={() => setResourceListOpen(false)} onNewTopic={isMessageOnlyView ? undefined : startDraftAssistantSelection} onCreateEmptyTopic={isClassicTopicLayout && !isMessageOnlyView ? createAndActivateEmptyTopic : undefined} diff --git a/src/renderer/pages/home/Tabs/HomeTabs.tsx b/src/renderer/pages/home/Tabs/HomeTabs.tsx index d65b6b9c3b3..ba37585534a 100644 --- a/src/renderer/pages/home/Tabs/HomeTabs.tsx +++ b/src/renderer/pages/home/Tabs/HomeTabs.tsx @@ -4,6 +4,7 @@ import type { } from '@renderer/components/chat/resourceList/base' import type { Topic } from '@renderer/types/topic' import { cn } from '@renderer/utils/style' +import type { TopicTabPosition } from '@shared/data/preference/preferenceTypes' import type { FC, HTMLAttributes } from 'react' import type { AddNewTopicPayload } from '../types' @@ -11,8 +12,13 @@ import { Topics } from './components/Topics' interface Props { activeTopic?: Topic + onActiveAssistantDeleted?: (assistantId: string) => void | Promise + onAddAssistant?: () => void | Promise + onCreateTopicAfterClear?: (payload: AddNewTopicPayload) => void | Promise onNewTopic?: (payload?: AddNewTopicPayload) => void | Promise onOpenHistoryRecords?: () => void + onSetPanePosition?: (position: TopicTabPosition) => void | Promise + panePosition?: TopicTabPosition setActiveTopic: (topic: Topic) => void revealRequest?: ResourceListRevealRequest resourceMenuItems?: readonly ConversationResourceMenuItem[] @@ -21,8 +27,13 @@ interface Props { const HomeTabs: FC = ({ activeTopic, + onActiveAssistantDeleted, + onAddAssistant, + onCreateTopicAfterClear, onNewTopic, onOpenHistoryRecords, + onSetPanePosition, + panePosition, setActiveTopic, revealRequest, resourceMenuItems, @@ -33,9 +44,14 @@ const HomeTabs: FC = ({ diff --git a/src/renderer/pages/home/Tabs/components/Topics.tsx b/src/renderer/pages/home/Tabs/components/Topics.tsx index a76059529b0..b3249d09109 100644 --- a/src/renderer/pages/home/Tabs/components/Topics.tsx +++ b/src/renderer/pages/home/Tabs/components/Topics.tsx @@ -6,15 +6,18 @@ import { useMultiplePreferences, usePreference } from '@data/hooks/usePreference import { loggerService } from '@logger' import { actionsToCommandMenuExtraItems } from '@renderer/components/chat/actions/actionMenuItems' import { ResourceListActionContextMenu } from '@renderer/components/chat/actions/ResourceListActionContextMenu' +import { useOptionalShellActions, useOptionalShellState } from '@renderer/components/chat/panes/Shell' import { - ConversationResourceMenu, type ConversationResourceMenuItem, + renderAssistantEntityIcon, + resolveDefaultCollapsedGroupIds, RESOURCE_LIST_RIGHT_PANEL_SEARCH_INPUT_CLASS, ResourceList, type ResourceListItemReorderPayload, type ResourceListReorderPayload, type ResourceListRevealRequest, type ResourceListSection, + TopicListOptionsMenu, useResourceListActions, useResourceListPinnedState, useResourceListRowState @@ -22,14 +25,13 @@ import { import { TopicResourceList } from '@renderer/components/chat/resourceList/TopicResourceList' import { CommandPopupMenu } from '@renderer/components/command' import EditNameDialog from '@renderer/components/EditNameDialog' -import EmojiIcon from '@renderer/components/EmojiIcon' import { ResourceEditDialogHost, type ResourceEditDialogTarget } from '@renderer/components/resourceCatalog/dialogs/edit' import { useAssistantTopicsSource } from '@renderer/hooks/resourceViewSources' import { useOptionalTabsContext } from '@renderer/hooks/tab' -import { useAssistantsApi } from '@renderer/hooks/useAssistant' +import { useAssistantMutations, useAssistantsApi } from '@renderer/hooks/useAssistant' import { useConversationNavigation } from '@renderer/hooks/useConversationNavigation' import { useNotesSettings } from '@renderer/hooks/useNotesSettings' import { usePins } from '@renderer/hooks/usePins' @@ -45,11 +47,12 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import type { Topic } from '@renderer/types/topic' import { fetchMessagesSummary } from '@renderer/utils/aiGeneration' import { formatErrorMessageWithPrefix } from '@renderer/utils/error' +import { pickNeighbourAfterRemoval } from '@renderer/utils/resourceEntity' import { cn } from '@renderer/utils/style' +import type { AssistantIconType, TopicTabPosition } from '@shared/data/preference/preferenceTypes' 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 { MoreHorizontal, PinIcon, Plus, 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' @@ -73,6 +76,7 @@ import { buildTopicDropAnchor, createTopicDisplayGroupResolver, getAssistantIdFromTopicGroupId, + getTopicAssistantDisplayGroupId, moveAssistantGroupAfterDrop, normalizeTopicDropPayload, sortTopicsForDisplayGroups, @@ -86,11 +90,22 @@ 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 +const TOPIC_ASSISTANT_TAG_SECTION_PREFIX = 'topic:section:assistant-tag:' +const TOPIC_ASSISTANT_UNTAGGED_SECTION_ID = `${TOPIC_ASSISTANT_TAG_SECTION_PREFIX}untagged` + interface Props { activeTopic?: Topic assistantIdFilter?: string | null + onActiveAssistantDeleted?: (assistantId: string) => void | Promise + onAddAssistant?: () => void | Promise + onCreateTopicAfterClear?: (payload: AddNewTopicPayload) => void | Promise onNewTopic?: (payload?: AddNewTopicPayload) => void | Promise onOpenHistoryRecords?: () => void + onSetPanePosition?: (position: TopicTabPosition) => void | Promise + panePosition?: TopicTabPosition presentation?: 'sidebar' | 'right-panel' revealRequest?: ResourceListRevealRequest resourceMenuItems?: readonly ConversationResourceMenuItem[] @@ -129,6 +144,12 @@ function findLatestCreateTopicPayload( return buildCreateTopicPayload(latestTopic, assistantById) } +function matchesAssistantFilter(topic: Topic, assistantIdFilter: string | null | undefined) { + if (assistantIdFilter === undefined) return false + if (assistantIdFilter === null) return !topic.assistantId + return topic.assistantId === assistantIdFilter +} + function resolveAssistantIdForTopicGroup( groupId: string, assistantById: ReadonlyMap @@ -141,47 +162,48 @@ function resolveAssistantIdForTopicGroup( return assistantId } -function TopicHistoryButton({ onOpenHistoryRecords }: { onOpenHistoryRecords?: () => void }) { - const { t } = useTranslation() - - if (!onOpenHistoryRecords) return null - - return ( - - - - - - ) -} - function AssistantGroupMoreMenu({ assistantId, + assistantIconType, + deleteAssistantDisabled, deleteTopicsDisabled, disabled, + isTagGrouping, pinned, + onDeleteAssistant, onDeleteAllTopics, onEdit, + onSetAssistantIconType, + onToggleTagGrouping, onTogglePin }: { assistantId: string + assistantIconType: AssistantIconType + deleteAssistantDisabled?: boolean deleteTopicsDisabled?: boolean disabled?: boolean + isTagGrouping: boolean pinned: boolean + onDeleteAssistant: (assistantId: string) => void | Promise onDeleteAllTopics: (assistantId: string) => void | Promise onEdit: (assistantId: string) => void + onSetAssistantIconType: (iconType: AssistantIconType) => void | Promise + onToggleTagGrouping: () => void | Promise onTogglePin: (assistantId: string) => void | Promise }) { const { t } = useTranslation() const actionContext: AssistantGroupActionContext = { assistantId, + assistantIconType, + deleteAssistantDisabled, deleteTopicsDisabled, disabled, + isTagGrouping, + onDeleteAssistant, onDeleteAllTopics, onEdit, + onSetAssistantIconType, + onToggleTagGrouping, onTogglePin, pinned, t @@ -206,8 +228,13 @@ function AssistantGroupMoreMenu({ export function Topics({ activeTopic, assistantIdFilter, + onActiveAssistantDeleted, + onAddAssistant, + onCreateTopicAfterClear, onNewTopic, onOpenHistoryRecords, + onSetPanePosition, + panePosition, presentation = 'sidebar', revealRequest, resourceMenuItems, @@ -225,7 +252,14 @@ export function Topics({ deleteTopicsByAssistantId, refreshTopics } = useTopicMutations() - const [topicDisplayMode] = usePreference('topic.tab.display_mode') + const [topicDisplayMode, setTopicDisplayMode] = usePreference('topic.tab.display_mode') + const [storedPanePosition, setStoredPanePosition] = usePreference('topic.tab.position') + const [assistantIconType, setAssistantIconType] = usePreference('assistant.icon_type') + const [assistantSortType, setAssistantSortType] = usePreference('assistant.tab.sort_type') + const [defaultModelId] = usePreference('chat.default_model_id') + const resolvedPanePosition = panePosition ?? storedPanePosition + const setResolvedPanePosition = onSetPanePosition ?? setStoredPanePosition + const isTagGrouping = assistantSortType === 'tags' const [topicExpansionTime, setTopicExpansionTime] = usePersistCache('ui.topic.expansion.time') const [topicExpansionAssistant, setTopicExpansionAssistant] = usePersistCache('ui.topic.expansion.assistant') const [renamingTopics] = useCache('topic.renaming') @@ -244,17 +278,13 @@ 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 [rightPanelTopicExpansion, setRightPanelTopicExpansion] = useState([]) - const topicExpansion = isRightPanel - ? rightPanelTopicExpansion - : isAssistantDisplayMode - ? topicExpansionAssistant - : topicExpansionTime - - useEffect(() => { - if (isRightPanel) setRightPanelTopicExpansion([]) - }, [assistantIdFilter, isRightPanel]) + const topicExpansion = isAssistantDisplayMode ? topicExpansionAssistant : topicExpansionTime const { isLoading: isTopicPinsLoading, @@ -285,11 +315,13 @@ export function Topics({ error: assistantsError, refetch: refreshAssistants } = useAssistantsApi({ enabled: isAssistantDisplayMode }) + const { deleteAssistant } = useAssistantMutations() const defaultAssistant = useMemo(() => ({ name: t('chat.default.name'), emoji: DEFAULT_ASSISTANT_EMOJI }), [t]) const listRef = useRef(null) const deleteTimerRef = useRef | null>(null) const [deletingTopicId, setDeletingTopicId] = useState(null) const [deletingAssistantGroupId, setDeletingAssistantGroupId] = useState(null) + const [deletingAssistantId, setDeletingAssistantId] = useState(null) const deletingAssistantGroupIdRef = useRef(null) const [editDialogTarget, setEditDialogTarget] = useState(null) @@ -477,19 +509,18 @@ export function Topics({ // The classic-layout right panel is scoped to a single assistant, so select that assistant's // neighbouring topic instead of the global next one (which could belong to another assistant). const selectionList = isRightPanel - ? topics.filter((candidate) => candidate.assistantId === assistantIdFilter) + ? topics.filter((candidate) => matchesAssistantFilter(candidate, assistantIdFilter)) : topics - if (selectionList.length <= 1) { - if (isRightPanel) { - await onNewTopic?.({ assistantId: assistantIdFilter ?? topic.assistantId ?? null }) - } + const next = pickNeighbourAfterRemoval(selectionList, topic.id) + if (next) { + setActiveTopic(next) return } - const index = findIndex(selectionList, (candidate) => candidate.id === topic.id) - if (index === -1) return - - setActiveTopic(selectionList[index + 1 === selectionList.length ? index - 1 : index + 1]) + // No neighbour left: only the classic panel auto-creates a fresh topic for the scoped assistant. + if (isRightPanel && selectionList.length <= 1) { + await onNewTopic?.({ assistantId: assistantIdFilter ?? topic.assistantId ?? null }) + } }, [activeTopic?.id, assistantIdFilter, isRightPanel, onNewTopic, removeTopic, setActiveTopic, t, topics] ) @@ -594,9 +625,18 @@ export function Topics({ return { id: TOPIC_PINNED_SECTION_ID, label: t('selector.common.pinned_title') } } + if (isTagGrouping) { + const assistant = topic.assistantId ? assistantById.get(topic.assistantId) : undefined + const tag = assistant?.tags?.[0]?.name?.trim() + + return tag + ? { id: `${TOPIC_ASSISTANT_TAG_SECTION_PREFIX}${encodeURIComponent(tag)}`, label: tag } + : { id: TOPIC_ASSISTANT_UNTAGGED_SECTION_ID, label: t('assistants.tags.untagged') } + } + return { id: TOPIC_ASSISTANT_SECTION_ID, label: t('chat.topics.display.assistant') } } - }, [isAssistantDisplayMode, t]) + }, [assistantById, isAssistantDisplayMode, isTagGrouping, t]) const baseGroupedTopics = useMemo( () => @@ -623,8 +663,7 @@ export function Topics({ const filteredTopics = useMemo(() => { if (!isRightPanel) return groupedTopics - if (!assistantIdFilter) return [] - return groupedTopics.filter((topic) => topic.assistantId === assistantIdFilter) + return groupedTopics.filter((topic) => matchesAssistantFilter(topic, assistantIdFilter)) }, [assistantIdFilter, groupedTopics, isRightPanel]) const headerCreateTopicPayload = useMemo( () => @@ -635,6 +674,11 @@ export function Topics({ : undefined, [assistantById, assistantIdFilter, filteredTopics, isAssistantDisplayMode, isRightPanel] ) + const headerCreateLabel = isAssistantDisplayMode ? t('chat.add.assistant.title') : t('chat.conversation.new') + const handleHeaderCreate = isAssistantDisplayMode + ? () => void onAddAssistant?.() + : () => void onNewTopic?.(headerCreateTopicPayload) + const showHeaderCreateItem = !(isAssistantDisplayMode && resolvedPanePosition === 'right') const getCreateTopicPayloadForGroup = useCallback( (groupId: string) => findLatestCreateTopicPayload(filteredTopics, (topic) => topicGroupBy(topic)?.id === groupId, assistantById), @@ -650,9 +694,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 = @@ -663,6 +710,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 }) }, []) @@ -701,13 +749,6 @@ export function Topics({ const targetTopics = topicsRef.current.filter((topic) => topic.assistantId === assistantId) if (targetTopics.length === 0) return - const targetTopicIds = new Set(targetTopics.map((topic) => topic.id)) - const remainingTopics = topicsRef.current.filter((topic) => !targetTopicIds.has(topic.id)) - if (remainingTopics.length === 0) { - window.toast.error(t('chat.topics.manage.error.at_least_one')) - return - } - deletingAssistantGroupIdRef.current = assistantId setDeletingAssistantGroupId(assistantId) @@ -729,21 +770,10 @@ export function Topics({ ) if (latestTargetTopicIds.size === 0) return - const latestRemainingTopics = topicsRef.current.filter((topic) => !latestTargetTopicIds.has(topic.id)) - if (latestRemainingTopics.length === 0) { - window.toast.error(t('chat.topics.manage.error.at_least_one')) - return - } - const result = await deleteTopicsByAssistantId(assistantId) - const successfulIds = new Set(result.deletedIds) - const actualRemainingTopics = topicsRef.current.filter((topic) => !successfulIds.has(topic.id)) - if (successfulIds.has(activeTopicIdRef.current) && actualRemainingTopics.length > 0) { - setActiveTopic(actualRemainingTopics[0]) - } - - window.toast.success(t('chat.topics.manage.delete.success', { count: result.deletedCount })) await refreshTopics() + await onCreateTopicAfterClear?.({ assistantId }) + window.toast.success(t('chat.topics.manage.delete.success', { count: result.deletedCount })) } catch (err) { logger.error('Failed to delete assistant topics', { assistantId, err }) window.toast.error(t('chat.topics.manage.delete.error')) @@ -752,7 +782,51 @@ export function Topics({ setDeletingAssistantGroupId(null) } }, - [deleteTopicsByAssistantId, refreshTopics, setActiveTopic, t] + [deleteTopicsByAssistantId, onCreateTopicAfterClear, refreshTopics, t] + ) + + const handleDeleteAssistant = useCallback( + async (assistantId: string) => { + if (deletingAssistantId) return + + setDeletingAssistantId(assistantId) + try { + const confirmed = await window.modal.confirm({ + title: t('assistants.delete.title'), + content: t('assistants.delete.content'), + okText: t('common.delete'), + cancelText: t('common.cancel'), + centered: true, + okButtonProps: { + danger: true + } + }) + if (!confirmed) return + + await deleteAssistant(assistantId, { deleteTopics: true }) + if (activeTopic?.assistantId === assistantId) { + await onActiveAssistantDeleted?.(assistantId) + } + + await refreshAssistants() + await refreshTopics() + window.toast.success(t('common.delete_success')) + } catch (err) { + logger.error('Failed to delete assistant from topic group', { assistantId, err }) + window.toast.error(formatErrorMessageWithPrefix(err, t('common.delete_failed'))) + } finally { + setDeletingAssistantId(null) + } + }, + [ + activeTopic?.assistantId, + deleteAssistant, + deletingAssistantId, + onActiveAssistantDeleted, + refreshAssistants, + refreshTopics, + t + ] ) const getGroupHeaderAction = useCallback( @@ -778,13 +852,21 @@ export function Topics({ topic.assistantId === assistantGroupId) + deletingAssistantGroupId !== null || + deletingAssistantId !== null || + !topics.some((topic) => topic.assistantId === assistantGroupId) } disabled={isAssistantPinActionDisabled} + isTagGrouping={isTagGrouping} + onDeleteAssistant={handleDeleteAssistant} pinned={assistantPinnedIdSet.has(assistantGroupId)} onDeleteAllTopics={handleDeleteAssistantTopics} onEdit={openAssistantEditor} + onSetAssistantIconType={setAssistantIconType} + onToggleTagGrouping={() => setAssistantSortType(isTagGrouping ? 'list' : 'tags')} onTogglePin={handleToggleAssistantPin} /> @@ -808,14 +890,20 @@ export function Topics({ [ assistantById, assistantPinnedIdSet, + assistantIconType, + deletingAssistantId, deletingAssistantGroupId, displayMode, getCreateTopicPayloadForGroup, + handleDeleteAssistant, handleDeleteAssistantTopics, handleToggleAssistantPin, isAssistantPinActionDisabled, + isTagGrouping, onNewTopic, openAssistantEditor, + setAssistantIconType, + setAssistantSortType, t, topics ] @@ -830,11 +918,19 @@ export function Topics({ const actionContext: AssistantGroupActionContext = { assistantId, + assistantIconType, + deleteAssistantDisabled: deletingAssistantId !== null, deleteTopicsDisabled: - deletingAssistantGroupId !== null || !topics.some((topic) => topic.assistantId === assistantId), + deletingAssistantGroupId !== null || + deletingAssistantId !== null || + !topics.some((topic) => topic.assistantId === assistantId), disabled: isAssistantPinActionDisabled, + isTagGrouping, + onDeleteAssistant: handleDeleteAssistant, onDeleteAllTopics: handleDeleteAssistantTopics, onEdit: openAssistantEditor, + onSetAssistantIconType: setAssistantIconType, + onToggleTagGrouping: () => setAssistantSortType(isTagGrouping ? 'list' : 'tags'), onTogglePin: handleToggleAssistantPin, pinned: assistantPinnedIdSet.has(assistantId), t @@ -847,13 +943,19 @@ export function Topics({ }, [ assistantById, + assistantIconType, assistantPinnedIdSet, + deletingAssistantId, deletingAssistantGroupId, displayMode, + handleDeleteAssistant, handleDeleteAssistantTopics, handleToggleAssistantPin, isAssistantPinActionDisabled, + isTagGrouping, openAssistantEditor, + setAssistantIconType, + setAssistantSortType, t, topics ] @@ -865,43 +967,70 @@ export function Topics({ if (group.id === TOPIC_UNLINKED_ASSISTANT_GROUP_ID) { if (group.label !== defaultAssistant.name) return null - return defaultAssistant.emoji ? ( - - ) : ( - - - - ) + return renderAssistantEntityIcon(assistantIconType, { + emoji: defaultAssistant.emoji, + modelId: defaultModelId + }) } const assistantId = getAssistantIdFromTopicGroupId(group.id) const assistant = assistantId ? assistantById.get(assistantId) : undefined if (!assistant) return undefined - return assistant.emoji ? ( - - ) : ( - - - - ) + return renderAssistantEntityIcon(assistantIconType, { + emoji: assistant.emoji, + modelId: assistant.modelId ?? defaultModelId, + modelName: assistant.modelName + }) }, - [assistantById, defaultAssistant.emoji, defaultAssistant.name, isAssistantDisplayMode] + [ + assistantById, + assistantIconType, + defaultAssistant.emoji, + defaultAssistant.name, + defaultModelId, + isAssistantDisplayMode + ] ) - const collapsedTopicState = topicExpansion + const collapsedTopicState = useMemo( + () => + isRightPanel + ? EMPTY_COLLAPSED_TOPIC_STATE + : resolveDefaultCollapsedGroupIds({ + collapsedIds: topicExpansion, + groupBy: topicGroupBy, + items: filteredTopics + }), + [filteredTopics, isRightPanel, topicExpansion, topicGroupBy] + ) const handleTopicCollapsedStateChange = useCallback( (nextCollapsedIds: string[]) => { - if (isRightPanel) { - setRightPanelTopicExpansion(nextCollapsedIds) - return - } + if (isRightPanel) return if (isAssistantDisplayMode) setTopicExpansionAssistant(nextCollapsedIds) else setTopicExpansionTime(nextCollapsedIds) }, [isAssistantDisplayMode, isRightPanel, setTopicExpansionAssistant, setTopicExpansionTime] ) + const handleTopicDisplayModeChange = useCallback( + (nextMode: TopicDisplayMode) => { + if (nextMode === 'assistant') { + const activeAssistantGroupId = activeTopic ? getTopicAssistantDisplayGroupId(activeTopic) : undefined + const collapsedAssistantGroupIds = Array.from( + new Set( + filteredTopics + .filter((topic) => !topic.pinned) + .map(getTopicAssistantDisplayGroupId) + .filter((groupId) => groupId !== activeAssistantGroupId) + ) + ) + setTopicExpansionAssistant(collapsedAssistantGroupIds) + } + void setTopicDisplayMode(nextMode) + }, + [activeTopic, filteredTopics, setTopicDisplayMode, setTopicExpansionAssistant] + ) const canDragTopicItem = useCallback( ({ item }: { item: Topic }) => isAssistantDisplayMode && !item.pinned, [isAssistantDisplayMode] @@ -1040,6 +1169,7 @@ export function Topics({ }, [assistantById, isAssistantDisplayMode, orderedAssistants, refreshAssistants, refreshTopics, t, topics] ) + const canSetPanePosition = isAssistantDisplayMode || isRightPanel return ( <> @@ -1053,8 +1183,8 @@ export function Topics({ sectionBy={topicSectionBy} collapsedState={collapsedTopicState} revealRequest={revealRequest} - defaultGroupVisibleCount={5} - groupLoadStep={5} + defaultGroupVisibleCount={defaultGroupVisibleCount} + groupLoadStep={isRightPanel ? Number.POSITIVE_INFINITY : DEFAULT_TOPIC_GROUP_VISIBLE_COUNT} getGroupHeaderAction={getGroupHeaderAction} getGroupHeaderContextMenu={getGroupHeaderContextMenu} getGroupHeaderIcon={getGroupHeaderIcon} @@ -1069,13 +1199,13 @@ 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} onCollapsedStateChange={handleTopicCollapsedStateChange}> - + {isRightPanel ? ( - ) : ( + ) : showHeaderCreateItem ? ( <> } - label={t('chat.conversation.new')} - onClick={() => void onNewTopic?.(headerCreateTopicPayload)} - actions={} + command={isAssistantDisplayMode ? undefined : 'topic.create'} + aria-label={headerCreateLabel} + disabled={isAssistantDisplayMode && !onAddAssistant} + icon={isAssistantDisplayMode ? : } + label={headerCreateLabel} + onClick={handleHeaderCreate} + actions={ + <> + + + } /> - + ) : ( + )} @@ -1118,7 +1268,9 @@ export function Topics({ onOpenInNewWindow={tabs ? openTopicInNewWindow : undefined} onPinTopic={handlePinTopic} onRequestTopicImageAction={handleTopicImageAction} + onSetPanePosition={canSetPanePosition ? setResolvedPanePosition : undefined} onSwitchTopic={setActiveTopic} + panePosition={canSetPanePosition ? resolvedPanePosition : undefined} topicsLength={topics.length} variant={isAssistantDisplayMode && !isRightPanel ? 'draggable' : 'plain'} /> @@ -1229,7 +1381,9 @@ interface TopicListBodyProps { onOpenInNewWindow?: (topic: Topic) => void onPinTopic: (topic: Topic) => Promise onRequestTopicImageAction: (type: TopicImageActionType, topic: Topic) => void + onSetPanePosition?: (position: TopicTabPosition) => void | Promise onSwitchTopic: (topic: Topic) => void + panePosition?: TopicTabPosition topicsLength: number variant: TopicListBodyVariant } @@ -1257,7 +1411,9 @@ function TopicListBody(props: TopicListBodyProps) { onOpenInNewWindow, onPinTopic, onRequestTopicImageAction, + onSetPanePosition, onSwitchTopic, + panePosition, topicsLength, variant } = props @@ -1281,7 +1437,9 @@ function TopicListBody(props: TopicListBodyProps) { onOpenInNewWindow, onPinTopic, onRequestTopicImageAction, + onSetPanePosition, onSwitchTopic, + panePosition, topicsLength }), [ @@ -1302,7 +1460,9 @@ function TopicListBody(props: TopicListBodyProps) { onOpenInNewWindow, onPinTopic, onRequestTopicImageAction, + onSetPanePosition, onSwitchTopic, + panePosition, topicsLength ] ) @@ -1353,11 +1513,15 @@ function TopicRow({ onOpenInNewWindow, onPinTopic, onRequestTopicImageAction, + onSetPanePosition, onSwitchTopic, + panePosition, topic, topicsLength }: TopicRowProps) { const { t } = useTranslation() + const shellState = useOptionalShellState() + const shellActions = useOptionalShellActions() const actions = useResourceListActions() const rowState = useResourceListRowState(topic.id) const streamStatus = useTopicListStreamStatus(topic.id) @@ -1407,7 +1571,9 @@ function TopicRow({ onOpenInNewTab, onOpenInNewWindow, onPinTopic, + onSetPanePosition, onStartRename: startMenuRename, + panePosition, t, topic, topicsLength @@ -1420,6 +1586,7 @@ function TopicRow({ className="relative" style={{ cursor: 'pointer' }} onClick={() => { + if (shellState?.maximized) shellActions?.minimize() onSwitchTopic(topic) }}> {showLeadingSlot && } 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 3a6fd9e7ccb..4f8b781f124 100644 --- a/src/renderer/pages/home/Tabs/components/__tests__/Topics.test.tsx +++ b/src/renderer/pages/home/Tabs/components/__tests__/Topics.test.tsx @@ -111,6 +111,12 @@ vi.mock('@renderer/components/resourceCatalog/dialogs/edit', () => ({ target ?
: null })) +vi.mock('@renderer/components/Avatar/ModelAvatar', () => ({ + default: ({ model, size }: { model: { id: string; providerId: string }; size: number }) => ( + + ) +})) + const topicDataMocks = vi.hoisted(() => ({ deleteTopicsByAssistantId: vi.fn().mockResolvedValue({ deletedIds: [] as string[], deletedCount: 0 }), deleteTopic: vi.fn().mockResolvedValue(undefined), @@ -123,6 +129,10 @@ const pinMutationMocks = vi.hoisted(() => ({ deletePin: vi.fn() })) +const assistantMutationMocks = vi.hoisted(() => ({ + deleteAssistant: vi.fn() +})) + const topicStreamStatusMocks = vi.hoisted(() => ({ markSeen: vi.fn(), statuses: new Map() @@ -250,6 +260,9 @@ vi.mock('react-i18next', () => ({ if (key === 'chat.topics.unpin') return 'Unpin Conversation' if (key === 'chat.topics.auto_rename') return 'Generate conversation name' if (key === 'chat.topics.edit.title') return 'Edit conversation name' + if (key === 'settings.topic.position.label') return 'Conversation position' + if (key === 'settings.topic.position.left') return 'Left' + if (key === 'settings.topic.position.right') return 'Right' if (key === 'chat.topics.empty.description') return 'Create a chat and it will stay here so you can continue with its context later.' if (key === 'chat.topics.empty.title') return 'No chats yet' @@ -257,6 +270,17 @@ 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.delete.title') return 'Delete Assistant' + if (key === 'assistants.delete.content') return 'Delete this assistant and its conversations?' + if (key === 'assistants.icon.type') return 'Assistant icon' + if (key === 'chat.add.assistant.title') return 'Add Assistant' + if (key === 'assistants.tags.group_by') return 'Group by tag' + if (key === 'assistants.tags.ungroup') return 'Ungroup tags' + if (key === 'assistants.tags.untagged') return 'Untagged' + if (key === 'settings.assistant.icon.type.emoji') return 'Emoji' + if (key === 'settings.assistant.icon.type.model') return 'Model' + if (key === 'settings.assistant.icon.type.none') return 'None' + 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' @@ -281,6 +305,8 @@ vi.mock('react-i18next', () => ({ if (key === 'chat.topics.export.joplin') return 'Export to Joplin' if (key === 'chat.topics.export.siyuan') return 'Export to Siyuan' if (key === 'common.delete') return 'Delete' + if (key === 'common.delete_success') return 'Deleted' + if (key === 'common.delete_failed') return 'Delete failed' if (key === 'common.more') return 'More' if (key === 'common.open_in_new_tab') return 'Open in new tab' if (key === 'tab.open_in_new_window') return 'Open in New Window' @@ -293,7 +319,6 @@ vi.mock('react-i18next', () => ({ if (key === 'chat.topics.manage.deselect_all') return 'Deselect All' if (key === 'chat.topics.manage.delete.confirm.title') return 'Delete Conversations' if (key === 'chat.topics.manage.delete.confirm.content') return `Delete ${options?.count ?? 0} conversation(s)?` - if (key === 'chat.topics.manage.error.at_least_one') return 'At least one conversation must be kept' if (key === 'chat.add.topic.title') return 'New Conversation' if (key === 'chat.default.name') return 'Default Assistant' if (key === 'common.prompt') return 'Prompt' @@ -344,7 +369,7 @@ const ALL_TOPIC_TIME_GROUP_IDS = [ type TopicGroupCollapseFixture = { time: string[] - assistant: string[] + assistant: string[] | null } // Default fixture: nothing collapsed (everything expanded). @@ -425,6 +450,7 @@ function createAssistant(overrides: Record = {}) { name: 'Alpha Assistant', emoji: '🧪', orderKey: 'a', + tags: [{ id: 'tag-work', name: 'Work' }], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', ...overrides @@ -436,16 +462,26 @@ type OnNewTopicMock = Mock<(payload?: { assistantId?: string | null }) => void> function renderTopicList({ activeTopic = createRendererTopic(), assistantIdFilter, + onActiveAssistantDeleted, + onAddAssistant = vi.fn(), + onCreateTopicAfterClear = vi.fn(), onNewTopic = vi.fn(), onOpenHistoryRecords = vi.fn(), + onSetPanePosition, + panePosition, presentation, revealRequest, resourceMenuItems }: { activeTopic?: Topic assistantIdFilter?: string | null + onActiveAssistantDeleted?: ComponentProps['onActiveAssistantDeleted'] + onAddAssistant?: ComponentProps['onAddAssistant'] + onCreateTopicAfterClear?: OnNewTopicMock onNewTopic?: OnNewTopicMock onOpenHistoryRecords?: Mock<() => void> + onSetPanePosition?: ComponentProps['onSetPanePosition'] + panePosition?: ComponentProps['panePosition'] presentation?: 'sidebar' | 'right-panel' revealRequest?: ResourceListRevealRequest resourceMenuItems?: ComponentProps['resourceMenuItems'] @@ -455,9 +491,14 @@ function renderTopicList({ @@ -536,7 +579,10 @@ describe('Topics', () => { cacheHookMocks.values.clear() setTopicGroupExpansionCache(createExpandedTopicGroupExpansionFixture()) MockUsePreferenceUtils.setMultiplePreferenceValues({ + 'assistant.icon_type': 'emoji', + 'assistant.tab.sort_type': 'list', 'topic.tab.display_mode': 'assistant', + 'topic.tab.position': 'left', 'data.export.menus.docx': true, 'data.export.menus.image': true, 'data.export.menus.joplin': true, @@ -551,6 +597,7 @@ describe('Topics', () => { }) pinMutationMocks.createPin.mockResolvedValue(createTopicPin()) pinMutationMocks.deletePin.mockResolvedValue(undefined) + assistantMutationMocks.deleteAssistant.mockResolvedValue(undefined) topicDataMocks.deleteTopicsByAssistantId.mockResolvedValue({ deletedIds: [], deletedCount: 0 }) tabsContextMocks.openTab.mockClear() tabsContextMocks.setActiveTab.mockClear() @@ -562,6 +609,9 @@ describe('Topics', () => { if (method === 'DELETE' && path === '/pins/:id') { return { trigger: pinMutationMocks.deletePin, isLoading: false, error: undefined } } + if (method === 'DELETE' && path === '/assistants/:id') { + return { trigger: assistantMutationMocks.deleteAssistant, isLoading: false, error: undefined } + } return { trigger: vi.fn(), isLoading: false, error: undefined } }) mockUseQuery.mockImplementation((path, options) => { @@ -591,7 +641,8 @@ describe('Topics', () => { id: 'assistant-2', name: 'Beta Assistant', emoji: '✍️', - orderKey: 'b' + orderKey: 'b', + tags: [{ id: 'tag-home', name: 'Home' }] }) ], total: 2 @@ -807,10 +858,28 @@ describe('Topics', () => { expect( screen.getByText('Create a chat and it will stay here so you can continue with its context later.') ).toBeInTheDocument() - expect(screen.getAllByRole('button', { name: 'chat.conversation.new' })).toHaveLength(1) + expect(screen.getByRole('button', { name: 'Add Assistant' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'chat.conversation.new' })).not.toBeInTheDocument() + expect(onNewTopic).not.toHaveBeenCalled() + }) + + it('uses the top header action to add an assistant in assistant display mode', () => { + const onAddAssistant = vi.fn() + const { onNewTopic } = renderTopicList({ onAddAssistant }) + + fireEvent.click(screen.getByRole('button', { name: 'Add Assistant' })) + + expect(onAddAssistant).toHaveBeenCalledTimes(1) expect(onNewTopic).not.toHaveBeenCalled() }) + it('hides the add assistant header action when topics are on the right', () => { + renderTopicList({ panePosition: 'right' }) + + expect(screen.queryByRole('button', { name: 'Add Assistant' })).not.toBeInTheDocument() + expect(screen.getByLabelText('Display mode')).toBeInTheDocument() + }) + it('uses only the redesigned search control in right panel mode', () => { renderTopicList({ assistantIdFilter: 'assistant-1', presentation: 'right-panel' }) @@ -822,6 +891,70 @@ describe('Topics', () => { expect(screen.getByRole('textbox', { name: 'Search conversations' })).toBeInTheDocument() }) + it('shows default assistant topics in right panel mode', () => { + mockUseInfiniteQuery.mockReturnValue({ + pages: [ + { + items: [ + createApiTopic({ + id: 'topic-default', + name: 'Default topic', + assistantId: undefined, + orderKey: 'a' + }), + createApiTopic({ + id: 'topic-alpha', + name: 'Alpha topic', + assistantId: 'assistant-1', + orderKey: 'b' + }) + ] + } + ], + isLoading: false, + isRefreshing: false, + error: undefined, + hasNext: false, + loadNext: vi.fn(), + refresh: vi.fn(), + reset: vi.fn(), + mutate: vi.fn() + }) + + renderTopicList({ + activeTopic: createRendererTopic({ id: 'topic-default', name: 'Default topic', assistantId: undefined }), + assistantIdFilter: null, + presentation: 'right-panel' + }) + + expect(screen.getByText('Default topic')).toBeInTheDocument() + expect(screen.queryByText('Alpha topic')).not.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 @@ -891,7 +1024,7 @@ describe('Topics', () => { expect(screen.getByText('Alpha topic')).toBeInTheDocument() }) - it('keeps pin actions in the topic context menu and removes topic position actions', () => { + it('keeps pin actions in the topic context menu and changes topic position from the menu', async () => { const { getByText } = renderTopicList() fireEvent.contextMenu(getByText('Alpha topic')) @@ -901,7 +1034,48 @@ describe('Topics', () => { expect(menuContent ?? null).toBeInTheDocument() expect(menuContent).toHaveTextContent('Pin Conversation') expect(menuContent).not.toHaveTextContent('Unpin Conversation') - expect(menuContent).not.toHaveTextContent('Topic position') + expect(menuContent).toHaveTextContent('Conversation position') + + fireEvent.click(within(menuContent as HTMLElement).getByText('Right')) + + await vi.waitFor(() => { + expect(MockUsePreferenceUtils.getPreferenceValue('topic.tab.position' as never)).toBe('right') + }) + }) + + it('hides topic position actions from the time-mode topic context menu', () => { + MockUsePreferenceUtils.setPreferenceValue('topic.tab.display_mode' as never, 'time') + const { getByText } = renderTopicList() + + fireEvent.contextMenu(getByText('Alpha topic')) + const alphaMenu = getByText('Alpha topic').closest('[data-testid="context-menu"]') + const menuContent = alphaMenu?.querySelector('[data-testid="context-menu-content"]') + + expect(menuContent ?? null).toBeInTheDocument() + expect(menuContent).not.toHaveTextContent('Conversation position') + }) + + it('changes the right-panel topic list to the left side from the context menu', async () => { + const onSetPanePosition = vi.fn() + const { getByText } = renderTopicList({ + assistantIdFilter: 'assistant-1', + onSetPanePosition, + panePosition: 'right', + presentation: 'right-panel' + }) + + fireEvent.contextMenu(getByText('Alpha topic')) + const alphaMenu = getByText('Alpha topic').closest('[data-testid="context-menu"]') + const menuContent = alphaMenu?.querySelector('[data-testid="context-menu-content"]') + + expect(menuContent ?? null).toBeInTheDocument() + const leftAction = within(menuContent as HTMLElement).getByRole('button', { name: 'Left' }) + expect(leftAction).not.toBeDisabled() + fireEvent.click(leftAction) + + await vi.waitFor(() => { + expect(onSetPanePosition).toHaveBeenCalledWith('left') + }) }) it('groups topic context menu actions and marks delete as destructive', () => { @@ -919,6 +1093,7 @@ describe('Topics', () => { 'Edit conversation name', 'Pin Conversation', 'Open in New Window', + 'Conversation positionLeftRight', 'Clear messages', '', 'Save to notes', @@ -1324,7 +1499,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') { @@ -1347,7 +1522,7 @@ describe('Topics', () => { } }) mockUseInfiniteQuery.mockReturnValue({ - pages: [{ items: createTopicPageItems(11) }], + pages: [{ items: createTopicPageItems(51) }], isLoading: false, isRefreshing: false, error: undefined, @@ -1361,19 +1536,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', () => { @@ -1399,7 +1573,7 @@ describe('Topics', () => { } }) mockUseInfiniteQuery.mockReturnValue({ - pages: [{ items: createTopicPageItems(11) }], + pages: [{ items: createTopicPageItems(51) }], isLoading: false, isRefreshing: false, error: undefined, @@ -1413,18 +1587,17 @@ 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() }) - it('hides assistant group bulk actions from the topic options menu', () => { + it('shows assistant group bulk actions in the topic options menu', async () => { MockUsePreferenceUtils.setPreferenceValue('topic.tab.display_mode' as never, 'assistant') mockUseInfiniteQuery.mockReturnValue({ pages: [ @@ -1471,17 +1644,23 @@ describe('Topics', () => { expect(screen.getByText('Beta topic 1')).toBeInTheDocument() expect(getTopicGroupExpansionCache().assistant).not.toContain(TOPIC_ASSISTANT_SECTION_ID) - // The assistant header exposes a direct history button and no bulk-action menu. + // The assistant header exposes history and bulk expand/collapse actions from the filter 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() + 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: 'Collapse all' })) + + expect(getTopicGroupExpansionCache().assistant).toEqual([ + 'topic:assistant:assistant-1', + 'topic:assistant:assistant-2' + ]) }) 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, @@ -1526,7 +1705,7 @@ describe('Topics', () => { } }) mockUseInfiniteQuery.mockReturnValue({ - pages: [{ items: createTopicPageItems(6) }], + pages: [{ items: createTopicPageItems(51) }], isLoading: false, isRefreshing: false, error: undefined, @@ -1542,10 +1721,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() } @@ -1602,24 +1781,75 @@ 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', async () => { 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() - - // 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')) + 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' })) + await vi.waitFor(() => { + expect(MockUsePreferenceUtils.getPreferenceValue('topic.tab.display_mode' as never)).toBe('time') + }) - fireEvent.click(historyButton) + fireEvent.click(screen.getByLabelText('Display mode')) + const reopenedDisplayModeMenu = screen.getByText('Display mode').closest('[data-testid="menu-list"]') + expect(reopenedDisplayModeMenu).not.toBeNull() + fireEvent.click(within(reopenedDisplayModeMenu as HTMLElement).getByRole('button', { name: 'History' })) expect(onOpenHistoryRecords).toHaveBeenCalledTimes(1) - expect(MockUsePreferenceUtils.getPreferenceValue('topic.tab.display_mode' as never)).toBe('assistant') + }) + + it('only expands the active assistant topic group when switching to assistant display mode from the menu', async () => { + MockUsePreferenceUtils.setPreferenceValue('topic.tab.display_mode' as never, 'time') + setTopicGroupExpansionCache({ + time: ['topic:time:yesterday'], + assistant: ['topic:assistant:assistant-1'] + }) + mockUseInfiniteQuery.mockReturnValue({ + pages: [ + { + items: [ + createApiTopic({ id: 'topic-a', name: 'Alpha topic', assistantId: 'assistant-1', orderKey: 'a' }), + createApiTopic({ id: 'topic-beta', name: 'Beta topic', assistantId: 'assistant-2', orderKey: 'b' }), + createApiTopic({ id: 'topic-default', name: 'Default topic', assistantId: undefined, orderKey: 'c' }) + ] + } + ], + isLoading: false, + isRefreshing: false, + error: undefined, + hasNext: false, + loadNext: vi.fn(), + refresh: vi.fn(), + reset: vi.fn(), + mutate: vi.fn() + }) + + renderTopicList() + + 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' })) + + await vi.waitFor(() => { + expect(MockUsePreferenceUtils.getPreferenceValue('topic.tab.display_mode' as never)).toBe('assistant') + expect(getTopicGroupExpansionCache().assistant).toHaveLength(2) + expect(getTopicGroupExpansionCache().assistant).toEqual( + expect.arrayContaining(['topic:assistant:unknown', 'topic:assistant:assistant-2']) + ) + }) + expect(getTopicGroupExpansionCache().time).toEqual(['topic:time:yesterday']) }) it('keeps assistant grouped topics in the generic loading state until all pages are ready', () => { @@ -1671,7 +1901,7 @@ describe('Topics', () => { mockUseInfiniteQuery.mockReturnValue({ pages: [ { - items: createTopicPageItems(6) + items: createTopicPageItems(51) } ], isLoading: false, @@ -1687,12 +1917,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') @@ -1740,51 +1970,6 @@ describe('Topics', () => { expect(onNewTopic).toHaveBeenCalledWith(undefined) }) - it('creates a topic from the header using the latest unpinned row', () => { - mockUseInfiniteQuery.mockReturnValue({ - pages: [ - { - items: [ - createApiTopic({ - id: 'topic-a', - name: 'Older alpha', - assistantId: 'assistant-1', - orderKey: 'a', - updatedAt: '2026-01-02T01:00:00.000Z' - }), - createApiTopic({ - id: 'topic-b', - name: 'Pinned newest alpha', - assistantId: 'assistant-1', - orderKey: 'b', - updatedAt: '2026-01-04T01:00:00.000Z' - }), - createApiTopic({ - id: 'topic-c', - name: 'Latest beta', - assistantId: 'assistant-2', - orderKey: 'c', - updatedAt: '2026-01-03T01:00:00.000Z' - }) - ] - } - ], - isLoading: false, - isRefreshing: false, - error: undefined, - hasNext: false, - loadNext: vi.fn(), - refresh: vi.fn(), - reset: vi.fn(), - mutate: vi.fn() - }) - const { onNewTopic } = renderTopicList() - - fireEvent.click(screen.getAllByRole('button', { name: 'chat.conversation.new' })[0]) - - expect(onNewTopic).toHaveBeenCalledWith({ assistantId: 'assistant-2' }) - }) - it('does not enable drag reorder in time mode', () => { const patchSpy = vi.spyOn(dataApiService, 'patch').mockResolvedValue(undefined as never) MockUsePreferenceUtils.setPreferenceValue('topic.tab.display_mode' as never, 'time') @@ -1797,6 +1982,19 @@ describe('Topics', () => { expect(patchSpy).not.toHaveBeenCalled() }) + it('defaults assistant display groups to collapsed before the user changes expansion', () => { + MockUsePreferenceUtils.setPreferenceValue('topic.tab.display_mode' as never, 'assistant') + setTopicGroupExpansionCache({ + ...createExpandedTopicGroupExpansionFixture(), + assistant: null + }) + + renderTopicList() + + expect(screen.getByRole('button', { name: 'Alpha Assistant' })).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('Topic A')).not.toBeInTheDocument() + }) + it('renders assistant groups and creates topics with the selected assistant payload', () => { MockUsePreferenceUtils.setPreferenceValue('topic.tab.display_mode' as never, 'assistant') setTopicGroupExpansionCache({ @@ -1912,6 +2110,18 @@ describe('Topics', () => { expect(screen.getByRole('button', { name: 'Default Assistant' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Alpha Assistant' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Beta Assistant' })).toBeInTheDocument() + expect( + screen + .getByRole('button', { name: 'Alpha Assistant' }) + .compareDocumentPosition(screen.getByRole('button', { name: 'Default Assistant' })) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + expect( + screen + .getByRole('button', { name: 'Beta Assistant' }) + .compareDocumentPosition(screen.getByRole('button', { name: 'Default Assistant' })) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() expect( screen.getByRole('button', { name: 'Alpha Assistant' }).querySelector('[data-resource-list-leading-slot="true"]') ?.firstElementChild @@ -1955,9 +2165,78 @@ describe('Topics', () => { } }) + it('keeps assistant tag sections when assistant topics move back to the left panel', () => { + MockUsePreferenceUtils.setMultiplePreferenceValues({ + 'assistant.tab.sort_type': 'tags', + 'topic.tab.display_mode': 'assistant', + 'topic.tab.position': 'left' + }) + + renderTopicList() + + const workSection = screen.getByRole('button', { name: 'Work' }).closest('div') + const homeSection = screen.getByRole('button', { name: 'Home' }).closest('div') + const alphaAssistant = screen.getByRole('button', { name: 'Alpha Assistant' }) + const betaAssistant = screen.getByRole('button', { name: 'Beta Assistant' }) + + expect(workSection).toBeInTheDocument() + expect(homeSection).toBeInTheDocument() + expect(alphaAssistant).toBeInTheDocument() + expect(betaAssistant).toBeInTheDocument() + expect(workSection!.compareDocumentPosition(alphaAssistant) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(homeSection!.compareDocumentPosition(betaAssistant) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + + it('uses the configured model icon for assistant topic groups', () => { + MockUsePreferenceUtils.setMultiplePreferenceValues({ + 'assistant.icon_type': 'model', + 'chat.default_model_id': 'provider-default::default-model', + 'topic.tab.display_mode': 'assistant' + }) + mockUseQuery.mockImplementation((path) => { + if (path === '/assistants') { + return { + data: { + items: [ + createAssistant({ + id: 'assistant-1', + modelId: 'provider-a::model-a', + modelName: 'Model A' + }) + ], + total: 1 + }, + isLoading: false, + isRefreshing: false, + error: undefined, + refetch: vi.fn().mockResolvedValue(undefined), + mutate: vi.fn().mockResolvedValue(undefined) + } + } + + return { + data: undefined, + isLoading: false, + isRefreshing: false, + error: undefined, + refetch: vi.fn().mockResolvedValue(undefined), + mutate: vi.fn().mockResolvedValue(undefined) + } + }) + + renderTopicList() + + const assistantHeader = screen.getByRole('button', { name: 'Alpha Assistant' }).closest('div') + expect(assistantHeader).toBeInTheDocument() + expect(within(assistantHeader as HTMLElement).getByTestId('model-avatar')).toHaveAttribute( + 'data-model-id', + 'model-a' + ) + }) + it('moves assistant group actions into the more menu', async () => { MockUsePreferenceUtils.setPreferenceValue('topic.tab.display_mode' as never, 'assistant') - const { onNewTopic, setActiveTopic } = renderTopicList() + const { onCreateTopicAfterClear, onNewTopic, setActiveTopic } = renderTopicList() const assistantGroupButton = screen.getByRole('button', { name: 'Alpha Assistant' }) const assistantHeader = assistantGroupButton.closest('div') @@ -2001,7 +2280,7 @@ describe('Topics', () => { const deleteAssistantChatsButton = within(assistantHeader as HTMLElement).getByRole('button', { name: 'Delete all assistant conversations' }) - expect(deleteAssistantChatsButton.querySelector('svg')).toHaveClass('lucide-custom', 'text-destructive') + expect(deleteAssistantChatsButton.querySelector('svg')).not.toHaveClass('text-destructive') topicDataMocks.deleteTopicsByAssistantId.mockResolvedValueOnce({ deletedIds: ['topic-a', 'topic-b'], deletedCount: 2 @@ -2019,11 +2298,61 @@ describe('Topics', () => { await vi.waitFor(() => expect(topicDataMocks.deleteTopicsByAssistantId).toHaveBeenCalledWith('assistant-1')) expect(topicDataMocks.deleteTopic).not.toHaveBeenCalled() await vi.waitFor(() => expect(topicDataMocks.refreshTopics).toHaveBeenCalled()) - expect(setActiveTopic).toHaveBeenCalledWith(expect.objectContaining({ id: 'topic-c' })) + expect(onCreateTopicAfterClear).toHaveBeenCalledWith({ assistantId: 'assistant-1' }) + expect(setActiveTopic).not.toHaveBeenCalled() expect(onNewTopic).not.toHaveBeenCalled() fireEvent.click(within(assistantHeader as HTMLElement).getByRole('button', { name: 'chat.conversation.new' })) expect(onNewTopic).toHaveBeenCalledWith({ assistantId: 'assistant-1' }) + + fireEvent.click(moreButton) + fireEvent.click(within(assistantHeader as HTMLElement).getByRole('button', { name: 'Model' })) + await vi.waitFor(() => + expect(MockUsePreferenceUtils.getPreferenceValue('assistant.icon_type' as never)).toBe('model') + ) + + fireEvent.click(moreButton) + fireEvent.click(within(assistantHeader as HTMLElement).getByRole('button', { name: 'Group by tag' })) + await vi.waitFor(() => + expect(MockUsePreferenceUtils.getPreferenceValue('assistant.tab.sort_type' as never)).toBe('tags') + ) + }) + + it('deletes an assistant from the left assistant group menu', async () => { + const onActiveAssistantDeleted = vi.fn() + MockUsePreferenceUtils.setPreferenceValue('topic.tab.display_mode' as never, 'assistant') + + renderTopicList({ onActiveAssistantDeleted }) + + const assistantHeader = screen.getByRole('button', { name: 'Alpha Assistant' }).closest('div') + expect(assistantHeader).toBeInTheDocument() + + const moreButton = within(assistantHeader as HTMLElement).getByRole('button', { name: 'More' }) + fireEvent.click(moreButton) + const deleteAssistantButton = within(assistantHeader as HTMLElement).getByRole('button', { + name: 'Delete Assistant' + }) + expect(deleteAssistantButton.querySelector('svg')).toHaveClass('lucide-custom', 'text-destructive') + + fireEvent.click(deleteAssistantButton) + + await vi.waitFor(() => + expect(window.modal.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Delete this assistant and its conversations?', + title: 'Delete Assistant' + }) + ) + ) + await vi.waitFor(() => + expect(assistantMutationMocks.deleteAssistant).toHaveBeenCalledWith({ + params: { id: 'assistant-1' }, + query: { deleteTopics: true } + }) + ) + expect(onActiveAssistantDeleted).toHaveBeenCalledWith('assistant-1') + await vi.waitFor(() => expect(topicDataMocks.refreshTopics).toHaveBeenCalled()) + expect(window.toast.success).toHaveBeenCalledWith('Deleted') }) it('blocks concurrent assistant group delete confirmations', async () => { @@ -2104,7 +2433,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: [ @@ -2113,12 +2443,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', () => { @@ -2134,7 +2477,7 @@ describe('Topics', () => { expect(screen.getAllByRole('button', { name: 'Edit Assistant' }).length).toBeGreaterThan(0) }) - it('keeps at least one topic when clearing an assistant group would delete all topics', async () => { + it('creates a fresh topic after clearing the only assistant group topics', async () => { MockUsePreferenceUtils.setPreferenceValue('topic.tab.display_mode' as never, 'assistant') mockUseInfiniteQuery.mockReturnValue({ pages: [ @@ -2165,7 +2508,11 @@ describe('Topics', () => { mutate: vi.fn() }) - renderTopicList() + const { onCreateTopicAfterClear } = renderTopicList() + topicDataMocks.deleteTopicsByAssistantId.mockResolvedValueOnce({ + deletedIds: ['topic-a', 'topic-b'], + deletedCount: 2 + }) const assistantHeader = screen.getByRole('button', { name: 'Alpha Assistant' }).closest('div') expect(assistantHeader).toBeInTheDocument() @@ -2176,11 +2523,12 @@ describe('Topics', () => { within(assistantHeader as HTMLElement).getByRole('button', { name: 'Delete all assistant conversations' }) ) - await vi.waitFor(() => expect(window.toast.error).toHaveBeenCalledWith('At least one conversation must be kept')) - expect(window.modal.confirm).not.toHaveBeenCalled() + await vi.waitFor(() => expect(window.modal.confirm).toHaveBeenCalled()) expect(topicDataMocks.deleteTopic).not.toHaveBeenCalled() - expect(topicDataMocks.deleteTopicsByAssistantId).not.toHaveBeenCalled() - expect(topicDataMocks.refreshTopics).not.toHaveBeenCalled() + await vi.waitFor(() => expect(topicDataMocks.deleteTopicsByAssistantId).toHaveBeenCalledWith('assistant-1')) + await vi.waitFor(() => expect(topicDataMocks.refreshTopics).toHaveBeenCalled()) + expect(onCreateTopicAfterClear).toHaveBeenCalledWith({ assistantId: 'assistant-1' }) + expect(window.toast.error).not.toHaveBeenCalled() }) it('keeps assistant pin reads disabled outside assistant display mode', () => { diff --git a/src/renderer/pages/home/Tabs/components/__tests__/topicsHelpers.test.ts b/src/renderer/pages/home/Tabs/components/__tests__/topicsHelpers.test.ts index 96f87cf8ee9..b6b7d390d0d 100644 --- a/src/renderer/pages/home/Tabs/components/__tests__/topicsHelpers.test.ts +++ b/src/renderer/pages/home/Tabs/components/__tests__/topicsHelpers.test.ts @@ -279,6 +279,7 @@ describe('Topics helpers', () => { const topics = [ createTopic({ id: 'assistant-b-1', assistantId: 'assistant-b' }), createTopic({ id: 'unknown-1', assistantId: 'missing-assistant' }), + createTopic({ id: 'default-1', assistantId: undefined }), createTopic({ id: 'assistant-a-1', assistantId: 'assistant-a' }), createTopic({ id: 'pinned-1', assistantId: 'missing-assistant', pinned: true }), createTopic({ id: 'assistant-b-2', assistantId: 'assistant-b' }) @@ -292,7 +293,7 @@ describe('Topics helpers', () => { ]), mode: 'assistant' }).map((topic) => topic.id) - ).toEqual(['pinned-1', 'assistant-a-1', 'assistant-b-1', 'assistant-b-2', 'unknown-1']) + ).toEqual(['pinned-1', 'assistant-a-1', 'assistant-b-1', 'assistant-b-2', 'default-1', 'unknown-1']) }) it('sorts assistant group topics by raw persisted orderKey ascending when available', () => { diff --git a/src/renderer/pages/home/Tabs/components/assistantGroupActions.tsx b/src/renderer/pages/home/Tabs/components/assistantGroupActions.tsx index 0b1f8bcc4b9..1f5a93a19ab 100644 --- a/src/renderer/pages/home/Tabs/components/assistantGroupActions.tsx +++ b/src/renderer/pages/home/Tabs/components/assistantGroupActions.tsx @@ -1,14 +1,27 @@ import { createActionRegistry } from '@renderer/components/chat/actions/actionRegistry' import type { ResolvedAction } from '@renderer/components/chat/actions/actionTypes' +import { + buildIconTypeActionDescriptors, + buildResourceEntityIconTypeActionDescriptor, + buildResourceEntityMenuActionDescriptor, + RESOURCE_ICON_TYPE_OPTIONS +} from '@renderer/components/chat/resourceList/base' +import type { AssistantIconType } from '@shared/data/preference/preferenceTypes' import type { TFunction } from 'i18next' -import { Edit3, PinIcon, PinOffIcon, Trash2 } from 'lucide-react' +import { BrushCleaning, Edit3, PinIcon, PinOffIcon, Smile, Tags, Trash2 } from 'lucide-react' export interface AssistantGroupActionContext { assistantId: string + assistantIconType: AssistantIconType + deleteAssistantDisabled?: boolean deleteTopicsDisabled?: boolean disabled?: boolean + isTagGrouping: boolean + onDeleteAssistant: (assistantId: string) => void | Promise onDeleteAllTopics: (assistantId: string) => void | Promise onEdit: (assistantId: string) => void + onSetAssistantIconType: (iconType: AssistantIconType) => void | Promise + onToggleTagGrouping: () => void | Promise onTogglePin: (assistantId: string) => void | Promise pinned: boolean t: TFunction @@ -37,35 +50,86 @@ assistantGroupActionRegistry.registerCommand({ run: ({ assistantId, onDeleteAllTopics }) => onDeleteAllTopics(assistantId) }) -assistantGroupActionRegistry.registerAction({ - id: 'assistant-group.edit', - commandId: 'assistant-group.edit', - label: ({ t }) => t('assistants.edit.title'), - icon: () => , - order: 10, - surface: 'menu' -}) +for (const type of RESOURCE_ICON_TYPE_OPTIONS) { + assistantGroupActionRegistry.registerCommand({ + id: `assistant-group.set-icon-type.${type}`, + run: ({ onSetAssistantIconType }) => onSetAssistantIconType(type) + }) +} -assistantGroupActionRegistry.registerAction({ - id: 'assistant-group.toggle-pin', - commandId: 'assistant-group.toggle-pin', - label: ({ pinned, t }) => (pinned ? t('assistants.unpin.title') : t('assistants.pin.title')), - icon: ({ pinned }) => (pinned ? : ), - order: 20, - surface: 'menu' +assistantGroupActionRegistry.registerCommand({ + id: 'assistant-group.toggle-tag-grouping', + run: ({ onToggleTagGrouping }) => onToggleTagGrouping() }) -assistantGroupActionRegistry.registerAction({ - id: 'assistant-group.delete-topics', - commandId: 'assistant-group.delete-topics', - label: ({ t }) => t('assistants.clear.menu_title'), - icon: () => , - group: 'danger', - order: 30, - surface: 'menu', - danger: true +assistantGroupActionRegistry.registerCommand({ + id: 'assistant-group.delete-assistant', + availability: ({ deleteAssistantDisabled }) => ({ enabled: !deleteAssistantDisabled }), + run: ({ assistantId, onDeleteAssistant }) => onDeleteAssistant(assistantId) }) +assistantGroupActionRegistry.registerAction( + buildResourceEntityMenuActionDescriptor({ + id: 'assistant-group.edit', + commandId: 'assistant-group.edit', + label: ({ t }) => t('assistants.edit.title'), + icon: () => , + order: 10 + }) +) + +assistantGroupActionRegistry.registerAction( + buildResourceEntityMenuActionDescriptor({ + id: 'assistant-group.toggle-pin', + commandId: 'assistant-group.toggle-pin', + label: ({ pinned, t }) => (pinned ? t('assistants.unpin.title') : t('assistants.pin.title')), + icon: ({ pinned }) => (pinned ? : ), + order: 20 + }) +) + +assistantGroupActionRegistry.registerAction( + buildResourceEntityMenuActionDescriptor({ + id: 'assistant-group.delete-topics', + commandId: 'assistant-group.delete-topics', + label: ({ t }) => t('assistants.clear.menu_title'), + icon: () => , + order: 25 + }) +) + +assistantGroupActionRegistry.registerAction( + buildResourceEntityIconTypeActionDescriptor({ + id: 'assistant-group.icon-type', + label: ({ t }) => t('assistants.icon.type'), + icon: () => , + order: 30, + children: buildIconTypeActionDescriptors('assistant-group.set-icon-type') + }) +) + +assistantGroupActionRegistry.registerAction( + buildResourceEntityMenuActionDescriptor({ + id: 'assistant-group.toggle-tag-grouping', + commandId: 'assistant-group.toggle-tag-grouping', + label: ({ isTagGrouping, t }) => (isTagGrouping ? t('assistants.tags.ungroup') : t('assistants.tags.group_by')), + icon: () => , + order: 35 + }) +) + +assistantGroupActionRegistry.registerAction( + buildResourceEntityMenuActionDescriptor({ + id: 'assistant-group.delete-assistant', + commandId: 'assistant-group.delete-assistant', + label: ({ t }) => t('assistants.delete.title'), + icon: () => , + group: 'danger', + order: 40, + danger: true + }) +) + export function resolveAssistantGroupActions(context: AssistantGroupActionContext): AssistantGroupAction[] { return assistantGroupActionRegistry.resolve(context, 'menu') } diff --git a/src/renderer/pages/home/Tabs/components/topicContextMenuActions.tsx b/src/renderer/pages/home/Tabs/components/topicContextMenuActions.tsx index ae689e5f9b3..503f8f7c87a 100644 --- a/src/renderer/pages/home/Tabs/components/topicContextMenuActions.tsx +++ b/src/renderer/pages/home/Tabs/components/topicContextMenuActions.tsx @@ -2,6 +2,7 @@ import { createActionRegistry } from '@renderer/components/chat/actions/actionRe import type { ResolvedAction } from '@renderer/components/chat/actions/actionTypes' import { OpenInNewWindowIcon } from '@renderer/components/icons/WindowIcons' import type { Topic } from '@renderer/types/topic' +import type { TopicTabPosition } from '@shared/data/preference/preferenceTypes' import type { TFunction } from 'i18next' import { BrushCleaning, @@ -12,6 +13,7 @@ import { FileText, Image, NotebookPen, + PanelLeft, PinIcon, PinOffIcon, Sparkles, @@ -60,7 +62,9 @@ export interface TopicActionContext { onPinTopic: TopicMenuHandler onSaveToKnowledge: TopicMenuHandler onSaveToNotes: TopicMenuHandler + onSetPanePosition?: (position: TopicTabPosition) => void | Promise onStartRename: TopicMenuHandler + panePosition?: TopicTabPosition t: TFunction topic: Topic topicsLength: number @@ -112,6 +116,24 @@ topicActionRegistry.registerCommand({ run: ({ onOpenInNewWindow, topic }) => onOpenInNewWindow?.(topic) }) +topicActionRegistry.registerCommand({ + id: 'topic.position-left', + availability: ({ onSetPanePosition, panePosition }) => ({ + visible: !!onSetPanePosition && !!panePosition, + enabled: !!onSetPanePosition && panePosition !== 'left' + }), + run: ({ onSetPanePosition }) => onSetPanePosition?.('left') +}) + +topicActionRegistry.registerCommand({ + id: 'topic.position-right', + availability: ({ onSetPanePosition, panePosition }) => ({ + visible: !!onSetPanePosition && !!panePosition, + enabled: !!onSetPanePosition && panePosition !== 'right' + }), + run: ({ onSetPanePosition }) => onSetPanePosition?.('right') +}) + topicActionRegistry.registerCommand({ id: 'topic.clear-messages', run: ({ onClearMessages, topic }) => onClearMessages(topic) @@ -239,6 +261,31 @@ topicActionRegistry.registerAction({ surface: 'menu' }) +topicActionRegistry.registerAction({ + id: 'topic.position', + label: ({ t }) => t('settings.topic.position.label'), + icon: () => , + order: 38, + surface: 'menu', + availability: ({ onSetPanePosition, panePosition }) => ({ visible: !!onSetPanePosition && !!panePosition }), + children: [ + { + id: 'topic.position-left', + commandId: 'topic.position-left', + label: ({ t }) => t('settings.topic.position.left'), + order: 10, + surface: 'menu' + }, + { + id: 'topic.position-right', + commandId: 'topic.position-right', + label: ({ t }) => t('settings.topic.position.right'), + order: 20, + surface: 'menu' + } + ] +}) + topicActionRegistry.registerAction({ id: 'topic.clear-messages', commandId: 'topic.clear-messages', diff --git a/src/renderer/pages/home/Tabs/components/topicsHelpers.ts b/src/renderer/pages/home/Tabs/components/topicsHelpers.ts index 8ac41644cec..ae1f00f1ee8 100644 --- a/src/renderer/pages/home/Tabs/components/topicsHelpers.ts +++ b/src/renderer/pages/home/Tabs/components/topicsHelpers.ts @@ -69,6 +69,7 @@ export const TOPIC_ASSISTANT_SECTION_ID = 'topic:section:assistant' export const TOPIC_UNLINKED_ASSISTANT_GROUP_ID = 'topic:assistant:unknown' const TOPIC_ASSISTANT_GROUP_ID_PREFIX = 'topic:assistant:' +const TOPIC_DEFAULT_ASSISTANT_RANK = Number.MAX_SAFE_INTEGER - 1 const TOPIC_UNLINKED_ASSISTANT_RANK = Number.MAX_SAFE_INTEGER export function moveTopicAfterDrop( @@ -180,6 +181,14 @@ export function getAssistantIdFromTopicGroupId(groupId: string): string | undefi return groupId.slice(TOPIC_ASSISTANT_GROUP_ID_PREFIX.length) } +export function getTopicAssistantGroupId(assistantId: string): string { + return `${TOPIC_ASSISTANT_GROUP_ID_PREFIX}${assistantId}` +} + +export function getTopicAssistantDisplayGroupId(topic: { assistantId?: string | null }): string { + return topic.assistantId ? getTopicAssistantGroupId(topic.assistantId) : TOPIC_UNLINKED_ASSISTANT_GROUP_ID +} + export function createTopicDisplayGroupResolver>({ assistantById, defaultAssistant, @@ -237,6 +246,10 @@ function getAssistantGroupRank>( return assistantRank + 1 } + if (!topic.assistantId) { + return TOPIC_DEFAULT_ASSISTANT_RANK + } + return TOPIC_UNLINKED_ASSISTANT_RANK } diff --git a/src/renderer/pages/home/Tabs/components/useTopicMenuActions.ts b/src/renderer/pages/home/Tabs/components/useTopicMenuActions.ts index 908d104688c..2e2d492c580 100644 --- a/src/renderer/pages/home/Tabs/components/useTopicMenuActions.ts +++ b/src/renderer/pages/home/Tabs/components/useTopicMenuActions.ts @@ -15,6 +15,7 @@ import { } from '@renderer/services/ExportService' import type { Topic } from '@renderer/types/topic' import { removeSpecialCharactersForFileName } from '@renderer/utils/file' +import type { TopicTabPosition } from '@shared/data/preference/preferenceTypes' import type { TFunction } from 'i18next' import { useCallback, useMemo } from 'react' @@ -40,7 +41,9 @@ export interface TopicMenuActionOptions { onOpenInNewTab?: TopicMenuHandler onOpenInNewWindow?: TopicMenuHandler onPinTopic: TopicMenuHandler + onSetPanePosition?: (position: TopicTabPosition) => void | Promise onStartRename: TopicMenuHandler + panePosition?: TopicTabPosition t: TFunction topic: Topic topicsLength: number @@ -59,7 +62,9 @@ export function createTopicActionContext({ onOpenInNewTab, onOpenInNewWindow, onPinTopic, + onSetPanePosition, onStartRename, + panePosition, t, topic, topicsLength @@ -102,6 +107,7 @@ export function createTopicActionContext({ onOpenInNewTab, onOpenInNewWindow, onPinTopic, + onSetPanePosition, onSaveToKnowledge: async (topic) => { try { const result = await SaveToKnowledgePopup.showForTopic(topic) @@ -114,6 +120,7 @@ export function createTopicActionContext({ }, onSaveToNotes: (topic) => exportTopicToNotes(topic, notesPath), onStartRename, + panePosition, t, topic, topicsLength @@ -186,7 +193,9 @@ export function useTopicMenuActions(options: TopicMenuActionOptions) { onOpenInNewTab, onOpenInNewWindow, onPinTopic, + onSetPanePosition, onStartRename, + panePosition, t, topic, topicsLength @@ -206,7 +215,9 @@ export function useTopicMenuActions(options: TopicMenuActionOptions) { onOpenInNewTab, onOpenInNewWindow, onPinTopic, + onSetPanePosition, onStartRename, + panePosition, t, topic, topicsLength @@ -224,7 +235,9 @@ export function useTopicMenuActions(options: TopicMenuActionOptions) { onOpenInNewTab, onOpenInNewWindow, onPinTopic, + onSetPanePosition, onStartRename, + panePosition, t, topic, topicsLength diff --git a/src/renderer/pages/home/__tests__/HomePage.test.tsx b/src/renderer/pages/home/__tests__/HomePage.test.tsx index 68c50f0ef51..e1d3567603d 100644 --- a/src/renderer/pages/home/__tests__/HomePage.test.tsx +++ b/src/renderer/pages/home/__tests__/HomePage.test.tsx @@ -155,15 +155,18 @@ vi.mock('@renderer/components/chat/shell/ConversationPageShell', () => ({ center, pane, paneOpen, + panePosition, topBar }: { center?: { content?: ReactNode } pane?: ReactNode paneOpen?: boolean + panePosition?: string topBar?: ReactNode }) => (
{String(paneOpen)} + {panePosition ?? ''}
{topBar}
{pane}
{center?.content}
@@ -399,6 +402,7 @@ vi.mock('../Chat', () => ({ activeTopic, pane, paneOpen, + panePosition, showResourceListControls, locateMessageId, resourcePaneCount, @@ -410,6 +414,7 @@ vi.mock('../Chat', () => ({ activeTopic: Topic pane?: ReactNode paneOpen?: boolean + panePosition?: string showResourceListControls?: boolean locateMessageId?: string resourcePaneCount?: { label: string; count: number } @@ -422,6 +427,7 @@ vi.mock('../Chat', () => ({ {activeTopic.id} {activeTopic.assistantId ?? ''} {String(paneOpen)} + {panePosition ?? ''} {String(showResourceListControls)} {locateMessageId ?? ''} {resourcePaneCount && ( @@ -477,16 +483,38 @@ vi.mock('../components/ChatNavbar', () => ({ })) vi.mock('../Tabs/HomeTabs', () => ({ - default: ({ onOpenHistoryRecords, resourceMenuItems, revealRequest }: any) => ( + default: ({ onOpenHistoryRecords, onSetPanePosition, resourceMenuItems, revealRequest, setActiveTopic }: any) => (
+ - {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 +571,13 @@ vi.mock('@renderer/components/chat/resourceList/AssistantResourceList', () => ({ activeAssistantId, onAddAssistant, onActiveAssistantDeleted, + onSelectedAssistantClick, resourceMenuItems }: { activeAssistantId?: string | null onAddAssistant?: () => void | Promise onActiveAssistantDeleted?: (assistantId: string) => void | Promise + onSelectedAssistantClick?: () => void | Promise resourceMenuItems?: Array<{ id: string; label: ReactNode; onSelect: () => void | Promise }> }) => (
@@ -557,11 +587,16 @@ vi.mock('@renderer/components/chat/resourceList/AssistantResourceList', () => ({ - {resourceMenuItems?.map((item) => ( - - ))} + + {resourceMenuItems + ?.filter((item) => item.id === 'assistant-resource-view') + .map((item) => ( + + ))}
) })) @@ -652,6 +687,7 @@ describe('HomePage', () => { homeMocks.forceActiveTopicUndefined = false homeMocks.preferenceValues.clear() homeMocks.preferenceValues.set('topic.tab.show', false) + homeMocks.preferenceValues.set('topic.tab.position', 'right') homeMocks.preferenceValues.set('chat.message.style', 'message-style') Object.defineProperty(window, 'api', { @@ -666,7 +702,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() @@ -678,10 +714,91 @@ describe('HomePage', () => { expect(screen.queryByTestId('home-tabs')).not.toBeInTheDocument() }) + it('does not render the topic resource pane when the classic topic position is left', () => { + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') + homeMocks.preferenceValues.set('topic.tab.position', 'left') + homeMocks.persistCacheValues.set('ui.chat.right_pane_open', true) + + render() + + expect(screen.getByTestId('home-tabs')).toBeInTheDocument() + expect(screen.getByTestId('topic-right-pane-provider')).toHaveAttribute('data-default-tab', 'branch') + expect(screen.queryByTestId('assistant-resource-list')).not.toBeInTheDocument() + expect(screen.queryByTestId('topic-resource-panel')).not.toBeInTheDocument() + expect(screen.queryByTestId('resource-pane-count')).not.toBeInTheDocument() + }) + + it('does not auto-open the topic right pane when switching to assistant display mode with left topic position', () => { + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') + homeMocks.preferenceValues.set('topic.tab.position', 'left') + homeMocks.persistCacheValues.set('ui.chat.right_pane_open', false) + + render() + + expect(screen.getByTestId('topic-right-pane-provider')).toHaveAttribute('data-default-open', 'false') + expect(homeMocks.cacheSetPersist).not.toHaveBeenCalledWith('ui.chat.right_pane_open', true) + }) + + 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') + homeMocks.preferenceValues.set('topic.tab.position', 'right') + + 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') + expect(screen.getByTestId('pane-position')).toHaveTextContent('left') + }) + + it('switches to assistant grouping when changing topic position from the left sidebar', async () => { + homeMocks.preferenceValues.set('topic.tab.display_mode', 'time') + homeMocks.preferenceValues.set('topic.tab.position', 'left') + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Move topics right' })) + + await waitFor(() => expect(homeMocks.preferenceValues.get('topic.tab.display_mode')).toBe('assistant')) + expect(homeMocks.preferenceValues.get('topic.tab.position')).toBe('right') + }) + + it('expands only the active topic assistant when changing topic position to the left sidebar', async () => { + homeMocks.preferenceValues.set('topic.tab.display_mode', 'time') + homeMocks.preferenceValues.set('topic.tab.position', 'right') + homeMocks.classicLayoutTopics = [ + { ...historyTopic, id: 'topic-a', assistantId: 'assistant-1' }, + { ...historyTopic, id: 'topic-b', assistantId: 'assistant-2' }, + { ...historyTopic, id: 'topic-c', assistantId: 'assistant-3' }, + { ...historyTopic, id: 'topic-default', assistantId: undefined } + ] + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Move topics left' })) + + await waitFor(() => expect(homeMocks.preferenceValues.get('topic.tab.position')).toBe('left')) + expect(homeMocks.persistCacheValues.get('ui.topic.expansion.assistant')).toEqual([ + 'topic:assistant:assistant-2', + 'topic:assistant:assistant-3', + 'topic:assistant:unknown' + ]) + }) + 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,11 +806,11 @@ 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() - 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() @@ -702,7 +819,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) => { @@ -712,7 +829,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' })) @@ -735,7 +852,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') @@ -755,7 +872,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(() => @@ -766,13 +883,13 @@ 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' }) 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' })) @@ -781,13 +898,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' })) @@ -795,7 +915,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' }, @@ -811,7 +931,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' } @@ -826,7 +946,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' }, @@ -846,8 +966,26 @@ describe('HomePage', () => { expect(homeMocks.createTopic).not.toHaveBeenCalled() }) + it('excludes the deleted active assistant when falling back to a draft after deletion', async () => { + homeMocks.preferenceValues.set('topic.tab.display_mode', 'assistant') + homeMocks.assistants = [{ id: 'assistant-1' }, { id: 'assistant-2' }] + homeMocks.persistCacheValues.set('ui.chat.last_used_assistant_id', 'assistant-1') + homeMocks.classicLayoutTopics = [ + { ...historyTopic, id: 'topic-a', assistantId: 'assistant-1', updatedAt: '2026-01-05T00:00:00.000Z' } + ] + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Delete active assistant' })) + + await waitFor(() => + expect(screen.getByTestId('draft-composer')).toHaveAttribute('data-assistant-id', 'assistant-2') + ) + expect(homeMocks.cacheSetPersist).toHaveBeenCalledWith('ui.chat.last_used_assistant_id', null) + }) + 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() @@ -862,7 +1000,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() @@ -883,7 +1021,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' }) @@ -898,7 +1036,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', @@ -927,7 +1065,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', @@ -955,7 +1093,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 = [ { @@ -977,7 +1115,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 = [ { @@ -1004,7 +1142,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 = [ @@ -1027,7 +1165,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. @@ -1043,7 +1181,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 = [ { @@ -1070,7 +1208,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() @@ -1087,7 +1225,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')) @@ -1143,6 +1281,18 @@ describe('HomePage', () => { expect(screen.getByTestId('pane-open')).toHaveTextContent('false') }) + 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(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 () => { render() diff --git a/src/renderer/pages/home/components/ChatNavbar.tsx b/src/renderer/pages/home/components/ChatNavbar.tsx index 52950ef4683..3993288b799 100644 --- a/src/renderer/pages/home/components/ChatNavbar.tsx +++ b/src/renderer/pages/home/components/ChatNavbar.tsx @@ -20,9 +20,7 @@ const HeaderNavbar: FC = ({ showSidebarControls = true, sideb const newTopic = useResolvedCommand('topic.create') return ( - +
{showSidebarControls && diff --git a/src/renderer/pages/home/components/TopicBranchPanel.tsx b/src/renderer/pages/home/components/TopicBranchPanel.tsx index e5518487871..bbb282b1679 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 && ( <> diff --git a/src/renderer/pages/home/components/__tests__/TopicRightPane.test.tsx b/src/renderer/pages/home/components/__tests__/TopicRightPane.test.tsx index 7977e28e91a..85f06952d62 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: 280, ARTIFACT_RIGHT_PANE_MAX_WIDTH: 720, - ARTIFACT_RIGHT_PANE_MIN_WIDTH: 360, + ARTIFACT_RIGHT_PANE_MIN_WIDTH: 280, RightPaneHost: ({ children, onCloseAnimationComplete, diff --git a/src/renderer/pages/launchpad/__tests__/LaunchpadPage.test.tsx b/src/renderer/pages/launchpad/__tests__/LaunchpadPage.test.tsx index d042c376cb2..b6aad76160d 100644 --- a/src/renderer/pages/launchpad/__tests__/LaunchpadPage.test.tsx +++ b/src/renderer/pages/launchpad/__tests__/LaunchpadPage.test.tsx @@ -122,7 +122,7 @@ vi.mock('react-i18next', () => ({ const label = { 'agent.sidebar_title': 'Agent', - 'agent.session.group.conversation': 'Chat', + 'title.chat': 'Chat', 'assistants.presets.title': 'Library', 'code.title': 'Code', 'files.title': 'Files', diff --git a/src/renderer/pages/settings/AppearanceSettings/AppearanceSettings.tsx b/src/renderer/pages/settings/AppearanceSettings/AppearanceSettings.tsx index 7bc8e1f98fd..afee2f978bc 100644 --- a/src/renderer/pages/settings/AppearanceSettings/AppearanceSettings.tsx +++ b/src/renderer/pages/settings/AppearanceSettings/AppearanceSettings.tsx @@ -33,7 +33,7 @@ import i18n from '@renderer/i18n/resolver' import { formatErrorMessage } from '@renderer/utils/error' import { isLinux, isMac } from '@renderer/utils/platform' import { cn } from '@renderer/utils/style' -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 { Minus, Monitor, Moon, Plus, Sun } from 'lucide-react' @@ -128,8 +128,6 @@ const AppearanceSettings: 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 [codeExecution, setCodeExecution] = useMultiplePreferences({ enabled: 'chat.code.execution.enabled', @@ -240,14 +238,6 @@ const AppearanceSettings: 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({ @@ -464,30 +454,6 @@ const AppearanceSettings: FC = () => { )} - - {t('settings.display.topic.title')} - - - {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" - /> - - - {t('settings.display.font.title')} New diff --git a/src/renderer/pages/settings/AppearanceSettings/__tests__/AppearanceSettings.test.tsx b/src/renderer/pages/settings/AppearanceSettings/__tests__/AppearanceSettings.test.tsx index decd007414b..adb3358e412 100644 --- a/src/renderer/pages/settings/AppearanceSettings/__tests__/AppearanceSettings.test.tsx +++ b/src/renderer/pages/settings/AppearanceSettings/__tests__/AppearanceSettings.test.tsx @@ -317,4 +317,16 @@ describe('AppearanceSettings 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/utils/__tests__/resourceEntity.test.ts b/src/renderer/utils/__tests__/resourceEntity.test.ts index 227e4c5059b..3a098bedc64 100644 --- a/src/renderer/utils/__tests__/resourceEntity.test.ts +++ b/src/renderer/utils/__tests__/resourceEntity.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { findLatestUpdated, isUntouchedSinceCreation } from '../resourceEntity' +import { findLatestUpdated, isUntouchedSinceCreation, pickNeighbourAfterRemoval } from '../resourceEntity' describe('resourceEntity', () => { describe('isUntouchedSinceCreation', () => { @@ -66,4 +66,23 @@ describe('resourceEntity', () => { expect(findLatestUpdated([first, second])).toBe(first) }) }) + + describe('pickNeighbourAfterRemoval', () => { + const list = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] + + it('picks the next row in display order', () => { + expect(pickNeighbourAfterRemoval(list, 'a')).toEqual({ id: 'b' }) + expect(pickNeighbourAfterRemoval(list, 'b')).toEqual({ id: 'c' }) + }) + + it('picks the previous row when the removed row was last', () => { + expect(pickNeighbourAfterRemoval(list, 'c')).toEqual({ id: 'b' }) + }) + + it('returns undefined when the id is absent or it was the only row', () => { + expect(pickNeighbourAfterRemoval(list, 'missing')).toBeUndefined() + expect(pickNeighbourAfterRemoval([{ id: 'only' }], 'only')).toBeUndefined() + expect(pickNeighbourAfterRemoval([], 'a')).toBeUndefined() + }) + }) }) diff --git a/src/renderer/utils/resourceEntity.ts b/src/renderer/utils/resourceEntity.ts index 98d0007290c..e274c019e2c 100644 --- a/src/renderer/utils/resourceEntity.ts +++ b/src/renderer/utils/resourceEntity.ts @@ -28,6 +28,26 @@ export function isUntouchedSinceCreation(item: { createdAt?: string; updatedAt?: return Math.abs(updatedAtMs - createdAtMs) <= UNTOUCHED_SINCE_CREATION_TOLERANCE_MS } +/** + * Selection policy for "which row becomes active after the active one is deleted": pick the next + * row in the given display-ordered list, or the previous row when the deleted row was last. Returns + * `undefined` when `id` is not present or was the only row (callers decide the empty fallback). + * + * `orderedList` must be the list in *visible display order* (and scoped to the surface the deleted + * row lived in), and must still contain the deleted row — call this on the pre-refresh snapshot. + * Centralizes the topic and agent-session delete-selection so both surfaces stay consistent instead + * of one picking the display neighbour and the other the raw API/orderKey head. + */ +export function pickNeighbourAfterRemoval( + orderedList: readonly T[], + id: string +): T | undefined { + if (orderedList.length <= 1) return undefined + const index = orderedList.findIndex((item) => item.id === id) + if (index === -1) return undefined + return orderedList[index + 1 === orderedList.length ? index - 1 : index + 1] +} + /** * Return the entity with the most recent `updatedAt` (ISO string). Ties keep the first item; * a missing or unparseable `updatedAt` sorts as oldest. Returns `undefined` for an empty list. diff --git a/src/shared/data/cache/cacheSchemas.ts b/src/shared/data/cache/cacheSchemas.ts index 928b04a4f5c..5b463db4a11 100644 --- a/src/shared/data/cache/cacheSchemas.ts +++ b/src/shared/data/cache/cacheSchemas.ts @@ -313,8 +313,9 @@ export type RendererPersistCacheSchema = { // Sidebar section/group collapse — one fixed key per display mode so toggling a group in one // mode never re-writes the others (avoids the whole-blob cross-mode/cross-window clobber). // Stores the flat list of collapsed section/group ids; empty = everything expanded. + // Null means no user preference has been written yet, so the view may apply its default. 'ui.topic.expansion.time': string[] - 'ui.topic.expansion.assistant': string[] + 'ui.topic.expansion.assistant': string[] | null 'ui.agent.last_used_session_id': string | null 'ui.agent.last_used_agent_id': string | null 'ui.agent.last_used_workspace_id': string | null @@ -322,8 +323,8 @@ export type RendererPersistCacheSchema = { // 'ui.chat.right_pane_open'); kept separate so the assistant and agent surfaces don't bleed. 'ui.agent.right_pane_open': boolean 'ui.agent.session.expansion.time': string[] - 'ui.agent.session.expansion.agent': string[] - 'ui.agent.session.expansion.workdir': string[] + 'ui.agent.session.expansion.agent': string[] | null + 'ui.agent.session.expansion.workdir': string[] | null 'settings.provider.last_selected_provider_id': string | null 'settings.provider.openai.alert.dismissed': boolean 'feature.mcp.is_uv_installed': boolean @@ -345,14 +346,14 @@ export const DefaultRendererPersistCache: RendererPersistCacheSchema = { 'ui.chat.last_used_topic_id': null, 'ui.chat.right_pane_open': false, 'ui.topic.expansion.time': [], - 'ui.topic.expansion.assistant': [], + 'ui.topic.expansion.assistant': null, 'ui.agent.last_used_session_id': null, 'ui.agent.last_used_agent_id': null, 'ui.agent.last_used_workspace_id': null, 'ui.agent.right_pane_open': false, 'ui.agent.session.expansion.time': [], - 'ui.agent.session.expansion.agent': [], - 'ui.agent.session.expansion.workdir': [], + 'ui.agent.session.expansion.agent': null, + 'ui.agent.session.expansion.workdir': null, 'settings.provider.last_selected_provider_id': null, 'settings.provider.openai.alert.dismissed': false, 'feature.mcp.is_uv_installed': false, 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..1710377eaa1 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-06T05:11:12.917Z * * This file is automatically generated from classification.json * To update this file, modify classification.json and run: @@ -37,9 +37,11 @@ import * as PreferenceTypes from '@shared/data/preference/preferenceTypes' export interface PreferenceSchemas { default: { // target-key-definitions/complex/complex - 'agent.layout': PreferenceTypes.ChatLayoutMode + 'agent.icon_type': PreferenceTypes.AssistantIconType // target-key-definitions/complex/complex 'agent.session.display_mode': PreferenceTypes.AgentSessionDisplayMode + // target-key-definitions/complex/complex + 'agent.session.position': PreferenceTypes.TopicTabPosition // redux/settings/enableDeveloperMode 'app.developer_mode.enabled': boolean // redux/settings/autoCheckUpdate @@ -458,8 +460,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 @@ -468,6 +468,8 @@ export interface PreferenceSchemas { 'topic.naming_prompt': string // target-key-definitions/complex/complex 'topic.tab.display_mode': PreferenceTypes.TopicDisplayMode + // redux/settings/topicPosition + 'topic.tab.position': PreferenceTypes.TopicTabPosition // redux/settings/showTopics 'topic.tab.show': boolean // redux/settings/customCss @@ -494,8 +496,9 @@ export interface PreferenceSchemas { /* eslint sort-keys: ["error", "asc", {"caseSensitive": true, "natural": false}] */ export const DefaultPreferences: PreferenceSchemas = { default: { - 'agent.layout': 'classic', + 'agent.icon_type': 'emoji', 'agent.session.display_mode': 'workdir', + 'agent.session.position': 'left', 'app.developer_mode.enabled': false, 'app.dist.auto_update.enabled': true, 'app.dist.test_plan.channel': PreferenceTypes.UpgradeChannel.LATEST, @@ -732,11 +735,11 @@ 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': '', 'topic.tab.display_mode': 'time', + 'topic.tab.position': 'left', 'topic.tab.show': true, 'ui.custom_css': '', 'ui.launchpad.app_order': [], @@ -759,9 +762,9 @@ export const DefaultPreferences: PreferenceSchemas = { /** * 生成统计: - * - 总配置项: 226 + * - 总配置项: 227 * - electronStore项: 1 - * - redux项: 173 + * - redux项: 174 * - localStorage项: 0 * - dexieSettings项: 4 */ diff --git a/src/shared/data/preference/preferenceTypes.ts b/src/shared/data/preference/preferenceTypes.ts index a35abba440d..7729b41737c 100644 --- a/src/shared/data/preference/preferenceTypes.ts +++ b/src/shared/data/preference/preferenceTypes.ts @@ -84,6 +84,8 @@ export type AssistantTabSortType = 'tags' | 'list' export type TopicDisplayMode = 'time' | 'assistant' +export type TopicTabPosition = 'left' | 'right' + export type AgentSessionDisplayMode = 'time' | 'agent' | 'workdir' export const SIDEBAR_FAVORITES = [ @@ -133,9 +135,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/classification.json b/v2-refactor-temp/tools/data-classify/data/classification.json index 99250e9c37e..ea62685c3d3 100644 --- a/v2-refactor-temp/tools/data-classify/data/classification.json +++ b/v2-refactor-temp/tools/data-classify/data/classification.json @@ -1037,11 +1037,11 @@ }, { "originalKey": "topicPosition", - "type": "string", + "type": "PreferenceTypes.TopicTabPosition", "defaultValue": "left", - "status": "deleted", - "category": null, - "targetKey": null + "status": "classified", + "category": "preferences", + "targetKey": "topic.tab.position" }, { "originalKey": "showTopicTime", 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..1238cb429d9 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[]", @@ -157,6 +143,13 @@ "status": "classified", "description": "Topic sidebar display grouping mode" }, + { + "targetKey": "agent.icon_type", + "type": "PreferenceTypes.AssistantIconType", + "defaultValue": "emoji", + "status": "classified", + "description": "Agent rail entity icon style (emoji/model/none), independent of the assistant icon type" + }, { "targetKey": "agent.session.display_mode", "type": "PreferenceTypes.AgentSessionDisplayMode", @@ -164,6 +157,13 @@ "status": "classified", "description": "Agent session sidebar display grouping mode" }, + { + "targetKey": "agent.session.position", + "type": "PreferenceTypes.TopicTabPosition", + "defaultValue": "left", + "status": "classified", + "description": "Agent session resource pane position (left/right), independent of the Home topic pane" + }, { "targetKey": "feature.quick_assistant.model_id", "type": "string",