diff --git a/src/renderer/src/components/WindowSideBar.vue b/src/renderer/src/components/WindowSideBar.vue index f8324d97b..6e4204761 100644 --- a/src/renderer/src/components/WindowSideBar.vue +++ b/src/renderer/src/components/WindowSideBar.vue @@ -142,88 +142,105 @@ - -
-
- -
+ +
+ +

{{ t('chat.sidebar.emptyTitle') }}

+

+ {{ t('chat.sidebar.emptyDescription') }} +

+
- -
- -

{{ t('chat.sidebar.emptyTitle') }}

-

- {{ t('chat.sidebar.emptyDescription') }} -

+ +
+
+ + + +
+ +
+
- @@ -583,7 +776,39 @@ onUnmounted(() => { -webkit-app-region: drag; } +.session-list { + overflow-anchor: none; +} + button { -webkit-app-region: no-drag; } + +:global(.sidebar-pin-flight) { + transform: translateZ(0); + backface-visibility: hidden; +} + +.sidebar-group-collapse-enter-active, +.sidebar-group-collapse-leave-active { + overflow: hidden; + transition: + max-height 180ms ease, + opacity 160ms ease, + transform 180ms ease; +} + +.sidebar-group-collapse-enter-from, +.sidebar-group-collapse-leave-to { + max-height: 0; + opacity: 0; + transform: translateY(-4px); +} + +.sidebar-group-collapse-enter-to, +.sidebar-group-collapse-leave-from { + max-height: 720px; + opacity: 1; + transform: translateY(0); +} diff --git a/src/renderer/src/components/WindowSideBarSessionItem.vue b/src/renderer/src/components/WindowSideBarSessionItem.vue index ac777ee08..8a3fd44cd 100644 --- a/src/renderer/src/components/WindowSideBarSessionItem.vue +++ b/src/renderer/src/components/WindowSideBarSessionItem.vue @@ -1,64 +1,434 @@ - - + + diff --git a/src/renderer/src/i18n/da-DK/chat.json b/src/renderer/src/i18n/da-DK/chat.json index 2264e1f8d..f6919666f 100644 --- a/src/renderer/src/i18n/da-DK/chat.json +++ b/src/renderer/src/i18n/da-DK/chat.json @@ -258,6 +258,7 @@ }, "groupByDate": "Gruppér efter dato", "groupByProject": "Gruppér efter projekt", + "pinned": "Fastgjorte", "emptyTitle": "Ingen samtaler endnu", "emptyDescription": "Start en ny samtale for at komme i gang", "remoteControlDisabled": "Deaktiveret" diff --git a/src/renderer/src/i18n/en-US/chat.json b/src/renderer/src/i18n/en-US/chat.json index 2a686a59a..14ba9737b 100644 --- a/src/renderer/src/i18n/en-US/chat.json +++ b/src/renderer/src/i18n/en-US/chat.json @@ -272,6 +272,7 @@ }, "groupByDate": "Group by date", "groupByProject": "Group by project", + "pinned": "Pinned", "emptyTitle": "No conversations yet", "emptyDescription": "Start a new chat to begin" } diff --git a/src/renderer/src/i18n/fa-IR/chat.json b/src/renderer/src/i18n/fa-IR/chat.json index 187c79bae..94f2d4914 100644 --- a/src/renderer/src/i18n/fa-IR/chat.json +++ b/src/renderer/src/i18n/fa-IR/chat.json @@ -258,6 +258,7 @@ }, "groupByDate": "گروه‌بندی بر اساس تاریخ", "groupByProject": "گروه‌بندی بر اساس پروژه", + "pinned": "سنجاق‌شده‌ها", "emptyTitle": "هنوز گفت‌وگویی وجود ندارد", "emptyDescription": "برای شروع، یک گفت‌وگوی جدید آغاز کنید", "remoteControlDisabled": "غیرفعال" diff --git a/src/renderer/src/i18n/fr-FR/chat.json b/src/renderer/src/i18n/fr-FR/chat.json index 4eaccd680..aa33493e0 100644 --- a/src/renderer/src/i18n/fr-FR/chat.json +++ b/src/renderer/src/i18n/fr-FR/chat.json @@ -258,6 +258,7 @@ }, "groupByDate": "Grouper par date", "groupByProject": "Grouper par projet", + "pinned": "Épinglés", "emptyTitle": "Aucune conversation pour le moment", "emptyDescription": "Commencez une nouvelle discussion pour démarrer", "remoteControlDisabled": "Désactivé" diff --git a/src/renderer/src/i18n/he-IL/chat.json b/src/renderer/src/i18n/he-IL/chat.json index 3adc1bbad..1fb8ae956 100644 --- a/src/renderer/src/i18n/he-IL/chat.json +++ b/src/renderer/src/i18n/he-IL/chat.json @@ -258,6 +258,7 @@ }, "groupByDate": "קבץ לפי תאריך", "groupByProject": "קבץ לפי פרויקט", + "pinned": "נעוצים", "emptyTitle": "עדיין אין שיחות", "emptyDescription": "התחילו שיחה חדשה כדי להתחיל", "remoteControlDisabled": "מושבת" diff --git a/src/renderer/src/i18n/ja-JP/chat.json b/src/renderer/src/i18n/ja-JP/chat.json index 2994b93c4..e620cb36c 100644 --- a/src/renderer/src/i18n/ja-JP/chat.json +++ b/src/renderer/src/i18n/ja-JP/chat.json @@ -258,6 +258,7 @@ }, "groupByDate": "日付でグループ化", "groupByProject": "プロジェクトでグループ化", + "pinned": "ピン留め", "emptyTitle": "まだ会話がありません", "emptyDescription": "新しいチャットを始めましょう", "remoteControlDisabled": "無効" diff --git a/src/renderer/src/i18n/ko-KR/chat.json b/src/renderer/src/i18n/ko-KR/chat.json index aadce8dcc..8421213ba 100644 --- a/src/renderer/src/i18n/ko-KR/chat.json +++ b/src/renderer/src/i18n/ko-KR/chat.json @@ -258,6 +258,7 @@ }, "groupByDate": "날짜별 그룹화", "groupByProject": "프로젝트별 그룹화", + "pinned": "고정됨", "emptyTitle": "아직 대화가 없습니다", "emptyDescription": "새 대화를 시작해 보세요", "remoteControlDisabled": "사용 안 함" diff --git a/src/renderer/src/i18n/pt-BR/chat.json b/src/renderer/src/i18n/pt-BR/chat.json index 1f750d9ed..1fda723c2 100644 --- a/src/renderer/src/i18n/pt-BR/chat.json +++ b/src/renderer/src/i18n/pt-BR/chat.json @@ -258,6 +258,7 @@ }, "groupByDate": "Agrupar por data", "groupByProject": "Agrupar por projeto", + "pinned": "Fixados", "emptyTitle": "Ainda não há conversas", "emptyDescription": "Inicie um novo chat para começar", "remoteControlDisabled": "Desativado" diff --git a/src/renderer/src/i18n/ru-RU/chat.json b/src/renderer/src/i18n/ru-RU/chat.json index 821ac4ad6..a537b7566 100644 --- a/src/renderer/src/i18n/ru-RU/chat.json +++ b/src/renderer/src/i18n/ru-RU/chat.json @@ -258,6 +258,7 @@ }, "groupByDate": "Группировать по дате", "groupByProject": "Группировать по проекту", + "pinned": "Закрепленные", "emptyTitle": "Пока нет диалогов", "emptyDescription": "Начните новый чат, чтобы приступить", "remoteControlDisabled": "Отключено" diff --git a/src/renderer/src/i18n/zh-CN/chat.json b/src/renderer/src/i18n/zh-CN/chat.json index 46a7fb588..a43c7a2db 100644 --- a/src/renderer/src/i18n/zh-CN/chat.json +++ b/src/renderer/src/i18n/zh-CN/chat.json @@ -272,6 +272,7 @@ }, "groupByDate": "按时间分组", "groupByProject": "按项目分组", + "pinned": "置顶会话", "emptyTitle": "还没有会话", "emptyDescription": "开始一个新会话吧" } diff --git a/src/renderer/src/i18n/zh-HK/chat.json b/src/renderer/src/i18n/zh-HK/chat.json index 51eab6b14..4ab05793e 100644 --- a/src/renderer/src/i18n/zh-HK/chat.json +++ b/src/renderer/src/i18n/zh-HK/chat.json @@ -258,6 +258,7 @@ }, "groupByDate": "按時間分組", "groupByProject": "按專案分組", + "pinned": "置頂會話", "emptyTitle": "還沒有對話", "emptyDescription": "開始一個新對話吧", "remoteControlDisabled": "未啟用" diff --git a/src/renderer/src/i18n/zh-TW/chat.json b/src/renderer/src/i18n/zh-TW/chat.json index 0d7b57d9c..86baadc85 100644 --- a/src/renderer/src/i18n/zh-TW/chat.json +++ b/src/renderer/src/i18n/zh-TW/chat.json @@ -258,6 +258,7 @@ }, "groupByDate": "依時間分組", "groupByProject": "依專案分組", + "pinned": "置頂會話", "emptyTitle": "還沒有對話", "emptyDescription": "開始新的對話吧", "remoteControlDisabled": "未啟用" diff --git a/test/renderer/components/WindowSideBar.test.ts b/test/renderer/components/WindowSideBar.test.ts index 3455346fe..5ef805b0d 100644 --- a/test/renderer/components/WindowSideBar.test.ts +++ b/test/renderer/components/WindowSideBar.test.ts @@ -3,6 +3,7 @@ import { defineComponent, reactive } from 'vue' import { flushPromises, mount } from '@vue/test-utils' type SetupOptions = { + groupMode?: 'time' | 'project' pinnedSessions?: Array<{ id: string; title: string; status: string; isPinned?: boolean }> groups?: Array<{ label: string @@ -40,7 +41,7 @@ const setup = async (options: SetupOptions = {}) => { }) const sessionStore = reactive({ - groupMode: 'time' as const, + groupMode: (options.groupMode ?? 'time') as 'time' | 'project', activeSessionId: 'session-1' as string | null, hasActiveSession: true, selectSession: vi.fn(async (id: string) => { @@ -238,10 +239,38 @@ describe('WindowSideBar agent switch', () => { await wrapper.vm.$nextTick() expect(wrapper.text()).toContain('Pinned Session') + expect(wrapper.text()).toContain('chat.sidebar.pinned') expect(wrapper.text()).toContain('common.time.today') expect(wrapper.text()).toContain('Normal Session') }, 10000) + it('collapses and expands pinned sessions from the pinned folder header', async () => { + const { wrapper } = await setup({ + pinnedSessions: [ + { + id: 'pinned-1', + title: 'Pinned Session', + status: 'none' + } + ] + }) + + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('chat.sidebar.pinned') + expect(wrapper.text()).toContain('Pinned Session') + + await wrapper.find('[data-group-id="__pinned__"]').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).not.toContain('Pinned Session') + + await wrapper.find('[data-group-id="__pinned__"]').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Pinned Session') + }, 10000) + it('toggles pinned state from a session item action', async () => { const session = { id: 'normal-1', @@ -261,12 +290,78 @@ describe('WindowSideBar agent switch', () => { const item = wrapper.findComponent({ name: 'WindowSideBarSessionItem' }) item.vm.$emit('toggle-pin', session) - await wrapper.vm.$nextTick() + await flushPromises() expect(sessionStore.toggleSessionPinned).toHaveBeenCalledWith('normal-1', true) }, 10000) - it('opens dialogs and dispatches rename, clear, and delete actions', async () => { + it('collapses and expands time groups from the folder header', async () => { + const { wrapper } = await setup({ + groups: [ + { + label: 'common.time.today', + labelKey: 'common.time.today', + sessions: [ + { + id: 'time-1', + title: 'Today Session', + status: 'none' + } + ] + } + ] + }) + + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('common.time.today') + expect(wrapper.text()).toContain('Today Session') + + await wrapper.find('[data-group-id="common.time.today"]').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).not.toContain('Today Session') + + await wrapper.find('[data-group-id="common.time.today"]').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Today Session') + }, 10000) + + it('collapses and expands project groups from the folder header', async () => { + const { wrapper } = await setup({ + groupMode: 'project', + groups: [ + { + label: 'DeepChat', + sessions: [ + { + id: 'project-1', + title: 'Project Session', + status: 'none' + } + ] + } + ] + }) + + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('DeepChat') + expect(wrapper.text()).toContain('Project Session') + + await wrapper.find('[data-group-id="DeepChat"]').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).not.toContain('Project Session') + + await wrapper.find('[data-group-id="DeepChat"]').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Project Session') + }, 10000) + + it('opens the delete dialog and dispatches delete actions', async () => { const session = { id: 'normal-1', title: 'Normal Session', @@ -285,20 +380,6 @@ describe('WindowSideBar agent switch', () => { const item = wrapper.findComponent({ name: 'WindowSideBarSessionItem' }) - item.vm.$emit('rename', session) - await wrapper.vm.$nextTick() - expect(wrapper.text()).toContain('dialog.rename.title') - ;(wrapper.vm as any).renameValue = 'Renamed Session' - await (wrapper.vm as any).handleRenameConfirm() - expect(sessionStore.renameSession).toHaveBeenCalledWith('normal-1', 'Renamed Session') - - item.vm.$emit('clear', session) - await wrapper.vm.$nextTick() - expect(wrapper.text()).toContain('dialog.cleanMessages.title') - - await (wrapper.vm as any).handleClearConfirm() - expect(sessionStore.clearSessionMessages).toHaveBeenCalledWith('normal-1') - item.vm.$emit('delete', session) await wrapper.vm.$nextTick() expect(wrapper.text()).toContain('dialog.delete.title') diff --git a/test/renderer/components/WindowSideBarSessionItem.test.ts b/test/renderer/components/WindowSideBarSessionItem.test.ts index 024cfa6c8..06d38beb4 100644 --- a/test/renderer/components/WindowSideBarSessionItem.test.ts +++ b/test/renderer/components/WindowSideBarSessionItem.test.ts @@ -1,22 +1,29 @@ import { describe, expect, it, vi } from 'vitest' -import { defineComponent } from 'vue' import { mount } from '@vue/test-utils' -const createSession = (isPinned = false) => ({ +const createSession = (options?: { + isPinned?: boolean + status?: 'none' | 'working' | 'completed' | 'error' +}) => ({ id: 'session-1', title: 'Session Title', agentId: 'deepchat', - status: 'none' as const, + status: options?.status ?? ('none' as const), projectDir: '', providerId: 'provider-1', modelId: 'model-1', - isPinned, + isPinned: options?.isPinned ?? false, isDraft: false, createdAt: 1, updatedAt: 1 }) -const mountComponent = async (isPinned = false) => { +const mountComponent = async (options?: { + isPinned?: boolean + status?: 'none' | 'working' | 'completed' | 'error' + heroHidden?: boolean + pinFeedbackMode?: 'pinning' | 'unpinning' | null +}) => { vi.resetModules() vi.doMock('vue-i18n', () => ({ @@ -25,30 +32,19 @@ const mountComponent = async (isPinned = false) => { }) })) - const passthrough = defineComponent({ - template: '
' - }) - - const contextMenuItemStub = defineComponent({ - emits: ['select'], - template: '' - }) - const WindowSideBarSessionItem = (await import('@/components/WindowSideBarSessionItem.vue')) .default const wrapper = mount(WindowSideBarSessionItem, { props: { - session: createSession(isPinned), - active: false + session: createSession(options), + active: false, + region: options?.isPinned ? 'pinned' : 'grouped', + heroHidden: options?.heroHidden ?? false, + pinFeedbackMode: options?.pinFeedbackMode ?? null }, global: { stubs: { - ContextMenu: passthrough, - ContextMenuTrigger: passthrough, - ContextMenuContent: passthrough, - ContextMenuSeparator: passthrough, - ContextMenuItem: contextMenuItemStub, Icon: true } } @@ -61,42 +57,63 @@ describe('WindowSideBarSessionItem', () => { it('emits select when the list item is clicked', async () => { const wrapper = await mountComponent() - await wrapper.find('button').trigger('click') + await wrapper.find('.session-item').trigger('click') expect(wrapper.emitted('select')?.[0]).toEqual([expect.objectContaining({ id: 'session-1' })]) }, 10000) it('renders the correct pin action label for pinned and unpinned sessions', async () => { - const unpinnedWrapper = await mountComponent(false) - const pinnedWrapper = await mountComponent(true) + const unpinnedWrapper = await mountComponent({ isPinned: false }) + const pinnedWrapper = await mountComponent({ isPinned: true }) - expect(unpinnedWrapper.text()).toContain('thread.actions.pin') - expect(pinnedWrapper.text()).toContain('thread.actions.unpin') + const unpinnedPinButton = unpinnedWrapper.find('[aria-label="thread.actions.pin"]') + const pinnedPinButton = pinnedWrapper.find('[aria-label="thread.actions.unpin"]') + + expect(unpinnedPinButton.exists()).toBe(true) + expect(unpinnedPinButton.attributes('aria-pressed')).toBe('false') + expect(pinnedPinButton.exists()).toBe(true) + expect(pinnedPinButton.attributes('aria-pressed')).toBe('true') }, 10000) - it('emits context menu actions with the session payload', async () => { + it('emits toggle-pin and delete with the session payload', async () => { const wrapper = await mountComponent() - const menuButtons = wrapper.findAll('button') - const renameButton = menuButtons.find((button) => - button.text().includes('thread.actions.rename') - ) - const clearButton = menuButtons.find((button) => - button.text().includes('thread.actions.cleanMessages') - ) - const deleteButton = menuButtons.find((button) => - button.text().includes('thread.actions.delete') - ) - - expect(renameButton).toBeTruthy() - expect(clearButton).toBeTruthy() - expect(deleteButton).toBeTruthy() - - await renameButton!.trigger('click') - await clearButton!.trigger('click') - await deleteButton!.trigger('click') - - expect(wrapper.emitted('rename')?.[0]).toEqual([expect.objectContaining({ id: 'session-1' })]) - expect(wrapper.emitted('clear')?.[0]).toEqual([expect.objectContaining({ id: 'session-1' })]) + + await wrapper.find('[aria-label="thread.actions.pin"]').trigger('click') + await wrapper.find('[aria-label="thread.actions.delete"]').trigger('click') + + expect(wrapper.emitted('toggle-pin')?.[0]).toEqual([ + expect.objectContaining({ id: 'session-1' }) + ]) expect(wrapper.emitted('delete')?.[0]).toEqual([expect.objectContaining({ id: 'session-1' })]) }, 10000) + + it('applies the loading shimmer to the title without rendering loading text', async () => { + const wrapper = await mountComponent({ status: 'working' }) + + const title = wrapper.find('.session-title') + const sheen = wrapper.find('.session-title__sheen') + + expect(title.classes()).toContain('session-title--loading') + expect(sheen.exists()).toBe(true) + expect(sheen.attributes('aria-hidden')).toBe('true') + expect(wrapper.find('.session-status-loading').exists()).toBe(false) + expect(wrapper.text()).not.toContain('common.loading') + expect(wrapper.find('[aria-label="thread.actions.pin"]').exists()).toBe(true) + }, 10000) + + it('exposes hero transition and pin feedback state on the rendered item', async () => { + const wrapper = await mountComponent({ + isPinned: true, + heroHidden: true, + pinFeedbackMode: 'pinning' + }) + + const item = wrapper.find('.session-item') + const pinButton = wrapper.find('.pin-button') + + expect(item.attributes('data-pin-fx')).toBe('pinning') + expect(item.attributes('data-session-id')).toBe('session-1') + expect(item.attributes('data-hero-hidden')).toBe('true') + expect(pinButton.attributes('data-pin-fx')).toBe('pinning') + }, 10000) })