From 85ef7e32d195af20d942bf999c8642c269e92647 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:44:31 +0000 Subject: [PATCH 1/7] Initial plan From 798bd378162a2cc2083772c37e82573cb95356d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:56:09 +0000 Subject: [PATCH 2/7] feat(chat): add lightweight chat search Agent-Logs-Url: https://github.com/ThinkInAIXYZ/deepchat/sessions/03e6c597-17cc-43b2-b07e-9690c5828beb Co-authored-by: zhangmo8 <43628500+zhangmo8@users.noreply.github.com> --- src/renderer/src/components/WindowSideBar.vue | 69 +++++++- .../components/WindowSideBarSessionItem.vue | 53 +++++- .../src/components/chat/ChatSearchBar.vue | 135 ++++++++++++++ src/renderer/src/i18n/da-DK/chat.json | 13 +- src/renderer/src/i18n/en-US/chat.json | 13 +- src/renderer/src/i18n/fa-IR/chat.json | 13 +- src/renderer/src/i18n/fr-FR/chat.json | 13 +- src/renderer/src/i18n/he-IL/chat.json | 13 +- src/renderer/src/i18n/ja-JP/chat.json | 13 +- src/renderer/src/i18n/ko-KR/chat.json | 13 +- src/renderer/src/i18n/pt-BR/chat.json | 13 +- src/renderer/src/i18n/ru-RU/chat.json | 13 +- src/renderer/src/i18n/zh-CN/chat.json | 13 +- src/renderer/src/i18n/zh-HK/chat.json | 13 +- src/renderer/src/i18n/zh-TW/chat.json | 13 +- src/renderer/src/lib/chatSearch.ts | 160 +++++++++++++++++ src/renderer/src/pages/ChatPage.vue | 167 ++++++++++++++++++ test/renderer/components/ChatPage.test.ts | 34 ++++ .../renderer/components/WindowSideBar.test.ts | 31 ++++ .../WindowSideBarSessionItem.test.ts | 14 +- test/renderer/lib/chatSearch.test.ts | 50 ++++++ 21 files changed, 851 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/components/chat/ChatSearchBar.vue create mode 100644 src/renderer/src/lib/chatSearch.ts create mode 100644 test/renderer/lib/chatSearch.test.ts diff --git a/src/renderer/src/components/WindowSideBar.vue b/src/renderer/src/components/WindowSideBar.vue index 6e4204761..7b99ead36 100644 --- a/src/renderer/src/components/WindowSideBar.vue +++ b/src/renderer/src/components/WindowSideBar.vue @@ -142,15 +142,53 @@ +
+
+ + + +
+
+
-

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

+

+ {{ + sessionSearchQuery + ? t('chat.sidebar.searchEmptyTitle') + : t('chat.sidebar.emptyTitle') + }} +

- {{ t('chat.sidebar.emptyDescription') }} + {{ + sessionSearchQuery + ? t('chat.sidebar.searchEmptyDescription') + : t('chat.sidebar.emptyDescription') + }}

@@ -189,6 +227,7 @@ region="pinned" :hero-hidden="pinFlightSessionId === session.id" :pin-feedback-mode="pinFeedbackSessionId === session.id ? pinFeedbackMode : null" + :search-query="sessionSearchQuery" @select="handleSessionClick" @toggle-pin="handleTogglePin" @delete="openDeleteDialog" @@ -229,6 +268,7 @@ region="grouped" :hero-hidden="pinFlightSessionId === session.id" :pin-feedback-mode="pinFeedbackSessionId === session.id ? pinFeedbackMode : null" + :search-query="sessionSearchQuery" @select="handleSessionClick" @toggle-pin="handleTogglePin" @delete="openDeleteDialog" @@ -269,6 +309,7 @@ import { TooltipTrigger } from '@shadcn/components/ui/tooltip' import { Button } from '@shadcn/components/ui/button' +import { Input } from '@shadcn/components/ui/input' import { Dialog, DialogContent, @@ -302,6 +343,7 @@ const agentStore = useAgentStore() const sessionStore = useSessionStore() const collapsed = ref(false) +const sessionSearchQuery = ref('') const remoteControlStatus = ref<{ telegram: TelegramRemoteStatus | null feishu: FeishuRemoteStatus | null @@ -381,8 +423,27 @@ const remoteControlIconClass = computed(() => { const isPinnedSectionCollapsed = ref(false) const collapsedGroupIds = ref>(new Set()) -const pinnedSessions = computed(() => sessionStore.getPinnedSessions(agentStore.selectedAgentId)) -const filteredGroups = computed(() => sessionStore.getFilteredGroups(agentStore.selectedAgentId)) +const normalizedSessionSearchQuery = computed(() => sessionSearchQuery.value.trim().toLocaleLowerCase()) +const matchesSessionSearch = (session: UISession) => { + if (!normalizedSessionSearchQuery.value) { + return true + } + + return session.title.toLocaleLowerCase().includes(normalizedSessionSearchQuery.value) +} +const pinnedSessions = computed(() => + sessionStore.getPinnedSessions(agentStore.selectedAgentId).filter(matchesSessionSearch) +) +const filteredGroups = computed(() => + sessionStore + .getFilteredGroups(agentStore.selectedAgentId) + .map((group) => ({ + label: group.label, + labelKey: group.labelKey, + sessions: group.sessions.filter(matchesSessionSearch) + })) + .filter((group) => group.sessions.length > 0) +) const pinFlightSessionId = ref(null) const pinFeedbackSessionId = ref(null) const pinFeedbackMode = ref(null) diff --git a/src/renderer/src/components/WindowSideBarSessionItem.vue b/src/renderer/src/components/WindowSideBarSessionItem.vue index 3579519d0..e7724bf18 100644 --- a/src/renderer/src/components/WindowSideBarSessionItem.vue +++ b/src/renderer/src/components/WindowSideBarSessionItem.vue @@ -18,6 +18,7 @@ const props = defineProps<{ region: SessionItemRegion heroHidden?: boolean pinFeedbackMode?: PinFeedbackMode | null + searchQuery?: string }>() const emit = defineEmits<{ @@ -32,6 +33,46 @@ const { session, active } = toRefs(props) const pinActionLabel = computed(() => session.value.isPinned ? t('thread.actions.unpin') : t('thread.actions.pin') ) + +const titleSegments = computed(() => { + const title = session.value.title + const query = props.searchQuery?.trim() + if (!query) { + return [{ text: title, match: false }] + } + + const lowerTitle = title.toLocaleLowerCase() + const lowerQuery = query.toLocaleLowerCase() + const segments: Array<{ text: string; match: boolean }> = [] + let searchIndex = 0 + let matchIndex = lowerTitle.indexOf(lowerQuery) + + while (matchIndex !== -1) { + if (matchIndex > searchIndex) { + segments.push({ + text: title.slice(searchIndex, matchIndex), + match: false + }) + } + + segments.push({ + text: title.slice(matchIndex, matchIndex + query.length), + match: true + }) + + searchIndex = matchIndex + query.length + matchIndex = lowerTitle.indexOf(lowerQuery, searchIndex) + } + + if (searchIndex < title.length) { + segments.push({ + text: title.slice(searchIndex), + match: false + }) + } + + return segments.length > 0 ? segments : [{ text: title, match: false }] +}) + + diff --git a/src/renderer/src/events.ts b/src/renderer/src/events.ts index d5ed808bc..c37978d7a 100644 --- a/src/renderer/src/events.ts +++ b/src/renderer/src/events.ts @@ -141,6 +141,7 @@ export const SHORTCUT_EVENTS = { ZOOM_OUT: 'shortcut:zoom-out', ZOOM_RESUME: 'shortcut:zoom-resume', CREATE_NEW_CONVERSATION: 'shortcut:create-new-conversation', + TOGGLE_SPOTLIGHT: 'shortcut:toggle-spotlight', GO_SETTINGS: 'shortcut:go-settings', CLEAN_CHAT_HISTORY: 'shortcut:clean-chat-history', DELETE_CONVERSATION: 'shortcut:delete-conversation' diff --git a/src/renderer/src/i18n/en-US/chat.json b/src/renderer/src/i18n/en-US/chat.json index a230484c0..5e04c9290 100644 --- a/src/renderer/src/i18n/en-US/chat.json +++ b/src/renderer/src/i18n/en-US/chat.json @@ -311,6 +311,20 @@ "searchEmptyTitle": "No matching conversations", "searchEmptyDescription": "Try a different title keyword" }, + "spotlight": { + "placeholder": "Search chats, messages, agents, settings, actions…", + "searching": "Searching…", + "emptyTitle": "No matching results", + "emptyDescription": "Try another keyword or open a recent session.", + "hints": "↑↓ move · Enter open · Esc close · Home/End jump", + "kind": { + "session": "Session", + "message": "Message", + "agent": "Agent", + "setting": "Setting", + "action": "Action" + } + }, "inlineSearch": { "placeholder": "Search in conversation", "ariaLabel": "Search in current conversation", diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index fc0f76266..e3f323ccf 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -1112,6 +1112,7 @@ "newWindow": "Open a new window", "showHideWindow": "Show/Hide window", "newConversation": "New conversation", + "quickSearch": "Spotlight search", "closeWindow": "Close the current window" }, "acp": { diff --git a/src/renderer/src/i18n/zh-CN/chat.json b/src/renderer/src/i18n/zh-CN/chat.json index cf6bf53f3..1bc9a5276 100644 --- a/src/renderer/src/i18n/zh-CN/chat.json +++ b/src/renderer/src/i18n/zh-CN/chat.json @@ -311,6 +311,20 @@ "searchEmptyTitle": "没有找到匹配的会话", "searchEmptyDescription": "试试换一个标题关键词" }, + "spotlight": { + "placeholder": "搜索会话、消息、Agent、设置与动作…", + "searching": "搜索中…", + "emptyTitle": "没有匹配结果", + "emptyDescription": "试试别的关键词,或直接打开最近会话。", + "hints": "↑↓ 选择 · Enter 打开 · Esc 关闭 · Home/End 跳转", + "kind": { + "session": "会话", + "message": "消息", + "agent": "Agent", + "setting": "设置", + "action": "动作" + } + }, "inlineSearch": { "placeholder": "搜索当前会话", "ariaLabel": "搜索当前会话", diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index d6fb802bc..ff86bf6b8 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -1112,6 +1112,7 @@ "newWindow": "打开新窗口", "showHideWindow": "显示/隐藏窗口", "newConversation": "新会话", + "quickSearch": "聚焦搜索", "closeWindow": "关闭当前窗口" }, "acp": { diff --git a/src/renderer/src/pages/ChatPage.vue b/src/renderer/src/pages/ChatPage.vue index 516fcffd9..0de8dec1c 100644 --- a/src/renderer/src/pages/ChatPage.vue +++ b/src/renderer/src/pages/ChatPage.vue @@ -121,6 +121,7 @@ import TraceDialog from '@/components/trace/TraceDialog.vue' import { useSessionStore } from '@/stores/ui/session' import { useMessageStore } from '@/stores/ui/message' import { usePendingInputStore } from '@/stores/ui/pendingInput' +import { useSpotlightStore } from '@/stores/ui/spotlight' import { useModelStore } from '@/stores/modelStore' import { usePresenter } from '@/composables/usePresenter' import { @@ -144,6 +145,7 @@ const props = defineProps<{ const sessionStore = useSessionStore() const messageStore = useMessageStore() const pendingInputStore = usePendingInputStore() +const spotlightStore = useSpotlightStore() const modelStore = useModelStore() const newAgentPresenter = usePresenter('newAgentPresenter') const { t } = useI18n() @@ -177,6 +179,7 @@ const chatSearchBarRef = ref<{ focusInput: () => void selectInput: () => void } | null>(null) +let spotlightJumpTimer: number | null = null function scrollToBottom() { const el = scrollContainer.value @@ -191,6 +194,47 @@ function onScroll() { isNearBottom.value = distanceFromBottom <= NEAR_BOTTOM_THRESHOLD } +async function focusPendingSpotlightMessageJump(attempt = 0): Promise { + const pendingJump = spotlightStore.pendingMessageJump + if (!pendingJump || pendingJump.sessionId !== props.sessionId) { + return + } + + await nextTick() + + const target = messageSearchRoot.value?.querySelector( + `[data-message-id="${pendingJump.messageId}"]` + ) + + if (!target) { + if (attempt >= 8) { + return + } + + if (spotlightJumpTimer) { + window.clearTimeout(spotlightJumpTimer) + } + + spotlightJumpTimer = window.setTimeout(() => { + void focusPendingSpotlightMessageJump(attempt + 1) + }, 80) + return + } + + target.scrollIntoView({ + block: 'center', + inline: 'nearest', + behavior: 'smooth' + }) + target.classList.add('message-highlight') + + window.setTimeout(() => { + target.classList.remove('message-highlight') + }, 2000) + + spotlightStore.clearPendingMessageJump() +} + // Load messages when sessionId changes, then scroll to bottom watch( () => props.sessionId, @@ -199,6 +243,10 @@ watch( if (id) { await Promise.all([messageStore.loadMessages(id), pendingInputStore.loadPendingInputs(id)]) await nextTick() + if (spotlightStore.pendingMessageJump?.sessionId === id) { + void focusPendingSpotlightMessageJump() + return + } scrollToBottom() return } @@ -370,6 +418,11 @@ const traceMessageIds = computed(() => watch( displayMessages, () => { + if (spotlightStore.pendingMessageJump?.sessionId === props.sessionId) { + void focusPendingSpotlightMessageJump() + return + } + if (isNearBottom.value) { nextTick(scrollToBottom) } @@ -855,11 +908,24 @@ onUnmounted(() => { window.removeEventListener('context-menu-ask-ai', handleContextMenuAskAI) window.removeEventListener('keydown', handleWindowKeydown) clearChatSearchHighlights(messageSearchRoot.value) + if (spotlightJumpTimer) { + window.clearTimeout(spotlightJumpTimer) + spotlightJumpTimer = null + } pendingInputStore.clear() }) diff --git a/src/renderer/src/i18n/da-DK/chat.json b/src/renderer/src/i18n/da-DK/chat.json index d29de33fd..d1304f50e 100644 --- a/src/renderer/src/i18n/da-DK/chat.json +++ b/src/renderer/src/i18n/da-DK/chat.json @@ -311,6 +311,20 @@ "subagents": { "label": "Underagenter" }, + "spotlight": { + "placeholder": "Søg i samtaler, agenter, indstillinger og handlinger…", + "searching": "Søger…", + "emptyTitle": "Ingen matchende resultater", + "emptyDescription": "Prøv et andet søgeord, eller åbn en nylig session.", + "hints": "↑↓ flyt · Enter åbn · Esc luk · Home/End hop", + "kind": { + "session": "Session", + "message": "Besked", + "agent": "Agent", + "setting": "Indstilling", + "action": "Handling" + } + }, "inlineSearch": { "placeholder": "Søg i samtalen", "ariaLabel": "Søg i den aktuelle samtale", diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 27d2c0b64..fbc5e62af 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -996,6 +996,7 @@ "newWindow": "Åbn nyt vindue", "showHideWindow": "Vis/skjul vindue", "newConversation": "Ny samtale", + "quickSearch": "Spotlight-søgning", "closeWindow": "Luk aktiv fane" }, "acp": { diff --git a/src/renderer/src/i18n/en-US/chat.json b/src/renderer/src/i18n/en-US/chat.json index 5e04c9290..5355e0823 100644 --- a/src/renderer/src/i18n/en-US/chat.json +++ b/src/renderer/src/i18n/en-US/chat.json @@ -312,7 +312,7 @@ "searchEmptyDescription": "Try a different title keyword" }, "spotlight": { - "placeholder": "Search chats, messages, agents, settings, actions…", + "placeholder": "Search chats, agents, settings, actions…", "searching": "Searching…", "emptyTitle": "No matching results", "emptyDescription": "Try another keyword or open a recent session.", diff --git a/src/renderer/src/i18n/fa-IR/chat.json b/src/renderer/src/i18n/fa-IR/chat.json index 61d6d7156..8195e68b9 100644 --- a/src/renderer/src/i18n/fa-IR/chat.json +++ b/src/renderer/src/i18n/fa-IR/chat.json @@ -311,6 +311,20 @@ "subagents": { "label": "زیرایجنت‌ها" }, + "spotlight": { + "placeholder": "جست‌وجوی گفتگوها، Agentها، تنظیمات و اقدامات…", + "searching": "در حال جست‌وجو…", + "emptyTitle": "نتیجهٔ مطابقی پیدا نشد", + "emptyDescription": "یک کلیدواژهٔ دیگر را امتحان کنید یا یک نشست اخیر را باز کنید.", + "hints": "↑↓ جابه‌جایی · Enter باز کردن · Esc بستن · Home/End پرش", + "kind": { + "session": "گفتگو", + "message": "پیام", + "agent": "Agent", + "setting": "تنظیمات", + "action": "اقدام" + } + }, "inlineSearch": { "placeholder": "جستجو در گفتگو", "ariaLabel": "جستجو در گفتگوی فعلی", diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index eafd4cde8..8fed0d56b 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -1050,6 +1050,7 @@ "newWindow": "باز کردن پنجره جدید", "showHideWindow": "نمایش/مخفی کردن پنجره", "newConversation": "گفت‌وگوی جدید", + "quickSearch": "جست‌وجوی Spotlight", "closeWindow": "بستن برگه کنونی" }, "rateLimit": { diff --git a/src/renderer/src/i18n/fr-FR/chat.json b/src/renderer/src/i18n/fr-FR/chat.json index aca97bbf6..af0f17f02 100644 --- a/src/renderer/src/i18n/fr-FR/chat.json +++ b/src/renderer/src/i18n/fr-FR/chat.json @@ -311,6 +311,20 @@ "subagents": { "label": "Sous-agents" }, + "spotlight": { + "placeholder": "Rechercher des discussions, agents, paramètres et actions…", + "searching": "Recherche…", + "emptyTitle": "Aucun résultat correspondant", + "emptyDescription": "Essayez un autre mot-clé ou ouvrez une session récente.", + "hints": "↑↓ déplacer · Entrée ouvrir · Échap fermer · Home/End aller", + "kind": { + "session": "Session", + "message": "Message", + "agent": "Agent", + "setting": "Paramètre", + "action": "Action" + } + }, "inlineSearch": { "placeholder": "Rechercher dans la conversation", "ariaLabel": "Rechercher dans la conversation actuelle", diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index 27c6b923f..c3997d617 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -1050,6 +1050,7 @@ "newWindow": "Ouvrez une nouvelle fenêtre", "showHideWindow": "Afficher/Masquer la fenêtre", "newConversation": "Nouvelle conversation", + "quickSearch": "Recherche Spotlight", "closeWindow": "Fermez la page d'onglet actuelle" }, "rateLimit": { diff --git a/src/renderer/src/i18n/he-IL/chat.json b/src/renderer/src/i18n/he-IL/chat.json index ac9ad1ffd..a773f3f11 100644 --- a/src/renderer/src/i18n/he-IL/chat.json +++ b/src/renderer/src/i18n/he-IL/chat.json @@ -311,6 +311,20 @@ "subagents": { "label": "סוכני משנה" }, + "spotlight": { + "placeholder": "חיפוש שיחות, סוכנים, הגדרות ופעולות…", + "searching": "מחפש…", + "emptyTitle": "לא נמצאו תוצאות מתאימות", + "emptyDescription": "נסו מילת מפתח אחרת או פתחו שיחה אחרונה.", + "hints": "↑↓ מעבר · Enter פתיחה · Esc סגירה · Home/End קפיצה", + "kind": { + "session": "שיחה", + "message": "הודעה", + "agent": "סוכן", + "setting": "הגדרה", + "action": "פעולה" + } + }, "inlineSearch": { "placeholder": "חיפוש בשיחה", "ariaLabel": "חיפוש בשיחה הנוכחית", diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index 3162dbc39..a7d0a6ddc 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -1050,6 +1050,7 @@ "newWindow": "פתח חלון חדש", "showHideWindow": "הצג/הסתר חלון", "newConversation": "שיחה חדשה", + "quickSearch": "חיפוש Spotlight", "closeWindow": "סגור את הכרטיסייה הנוכחית" }, "acp": { diff --git a/src/renderer/src/i18n/ja-JP/chat.json b/src/renderer/src/i18n/ja-JP/chat.json index d0d4e906c..507bdb3df 100644 --- a/src/renderer/src/i18n/ja-JP/chat.json +++ b/src/renderer/src/i18n/ja-JP/chat.json @@ -311,6 +311,20 @@ "subagents": { "label": "サブエージェント" }, + "spotlight": { + "placeholder": "会話、Agent、設定、アクションを検索…", + "searching": "検索中…", + "emptyTitle": "一致する結果がありません", + "emptyDescription": "別のキーワードを試すか、最近のセッションを開いてください。", + "hints": "↑↓ 移動 · Enter 開く · Esc 閉じる · Home/End ジャンプ", + "kind": { + "session": "セッション", + "message": "メッセージ", + "agent": "Agent", + "setting": "設定", + "action": "アクション" + } + }, "inlineSearch": { "placeholder": "現在の会話を検索", "ariaLabel": "現在の会話を検索", diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index 9133bb0a8..706998ad9 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -1050,6 +1050,7 @@ "newWindow": "新しいウィンドウを開きます", "showHideWindow": "ウィンドウを表示または非表示にします", "newConversation": "新しい会話", + "quickSearch": "Spotlight 検索", "closeWindow": "現在のタブページを閉じます" }, "rateLimit": { diff --git a/src/renderer/src/i18n/ko-KR/chat.json b/src/renderer/src/i18n/ko-KR/chat.json index 4d187ed92..f350a66b8 100644 --- a/src/renderer/src/i18n/ko-KR/chat.json +++ b/src/renderer/src/i18n/ko-KR/chat.json @@ -311,6 +311,20 @@ "subagents": { "label": "하위 에이전트" }, + "spotlight": { + "placeholder": "대화, Agent, 설정, 동작 검색…", + "searching": "검색 중…", + "emptyTitle": "일치하는 결과가 없습니다", + "emptyDescription": "다른 키워드를 시도하거나 최근 세션을 열어 보세요.", + "hints": "↑↓ 이동 · Enter 열기 · Esc 닫기 · Home/End 이동", + "kind": { + "session": "세션", + "message": "메시지", + "agent": "Agent", + "setting": "설정", + "action": "동작" + } + }, "inlineSearch": { "placeholder": "현재 대화 검색", "ariaLabel": "현재 대화 검색", diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index ee863133e..4cbef4729 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -1050,6 +1050,7 @@ "newWindow": "새 창을 엽니 다", "showHideWindow": "창을 표시/숨기기", "newConversation": "새로운 대화", + "quickSearch": "Spotlight 검색", "closeWindow": "현재 탭 페이지를 닫습니다" }, "rateLimit": { diff --git a/src/renderer/src/i18n/pt-BR/chat.json b/src/renderer/src/i18n/pt-BR/chat.json index f4d90c894..da18f08d8 100644 --- a/src/renderer/src/i18n/pt-BR/chat.json +++ b/src/renderer/src/i18n/pt-BR/chat.json @@ -311,6 +311,20 @@ "subagents": { "label": "Subagentes" }, + "spotlight": { + "placeholder": "Pesquisar conversas, agentes, configurações e ações…", + "searching": "Pesquisando…", + "emptyTitle": "Nenhum resultado correspondente", + "emptyDescription": "Tente outra palavra-chave ou abra uma sessão recente.", + "hints": "↑↓ mover · Enter abrir · Esc fechar · Home/End saltar", + "kind": { + "session": "Sessão", + "message": "Mensagem", + "agent": "Agente", + "setting": "Configuração", + "action": "Ação" + } + }, "inlineSearch": { "placeholder": "Pesquisar na conversa", "ariaLabel": "Pesquisar na conversa atual", diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index 2c30b4cc2..91d6d788d 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -1050,6 +1050,7 @@ "newWindow": "Abrir uma nova janela", "showHideWindow": "Mostrar/Ocultar janela", "newConversation": "Nova conversa", + "quickSearch": "Busca Spotlight", "closeWindow": "Fechar a guia atual" }, "rateLimit": { diff --git a/src/renderer/src/i18n/ru-RU/chat.json b/src/renderer/src/i18n/ru-RU/chat.json index f75f077f9..0c6b6c2ca 100644 --- a/src/renderer/src/i18n/ru-RU/chat.json +++ b/src/renderer/src/i18n/ru-RU/chat.json @@ -311,6 +311,20 @@ "subagents": { "label": "Субагенты" }, + "spotlight": { + "placeholder": "Искать чаты, агентов, настройки и действия…", + "searching": "Поиск…", + "emptyTitle": "Совпадений не найдено", + "emptyDescription": "Попробуйте другой запрос или откройте недавнюю сессию.", + "hints": "↑↓ перемещение · Enter открыть · Esc закрыть · Home/End переход", + "kind": { + "session": "Сессия", + "message": "Сообщение", + "agent": "Агент", + "setting": "Настройка", + "action": "Действие" + } + }, "inlineSearch": { "placeholder": "Поиск по текущему чату", "ariaLabel": "Поиск по текущему чату", diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index b1692366f..d53d6aa62 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -1050,6 +1050,7 @@ "newWindow": "Откройте новое окно", "showHideWindow": "Показать/Скрыть окно", "newConversation": "Новый разговор", + "quickSearch": "Поиск Spotlight", "closeWindow": "Закройте текущую страницу вкладки" }, "rateLimit": { diff --git a/src/renderer/src/i18n/zh-CN/chat.json b/src/renderer/src/i18n/zh-CN/chat.json index 1bc9a5276..62a79b85c 100644 --- a/src/renderer/src/i18n/zh-CN/chat.json +++ b/src/renderer/src/i18n/zh-CN/chat.json @@ -312,7 +312,7 @@ "searchEmptyDescription": "试试换一个标题关键词" }, "spotlight": { - "placeholder": "搜索会话、消息、Agent、设置与动作…", + "placeholder": "搜索会话、Agent、设置与动作…", "searching": "搜索中…", "emptyTitle": "没有匹配结果", "emptyDescription": "试试别的关键词,或直接打开最近会话。", diff --git a/src/renderer/src/i18n/zh-HK/chat.json b/src/renderer/src/i18n/zh-HK/chat.json index 27b11277b..0c68327ac 100644 --- a/src/renderer/src/i18n/zh-HK/chat.json +++ b/src/renderer/src/i18n/zh-HK/chat.json @@ -311,6 +311,20 @@ "subagents": { "label": "subagent" }, + "spotlight": { + "placeholder": "搜尋會話、Agent、設定與動作…", + "searching": "搜尋中…", + "emptyTitle": "沒有相符結果", + "emptyDescription": "試試其他關鍵字,或直接開啟最近會話。", + "hints": "↑↓ 選擇 · Enter 開啟 · Esc 關閉 · Home/End 跳轉", + "kind": { + "session": "會話", + "message": "訊息", + "agent": "Agent", + "setting": "設定", + "action": "動作" + } + }, "inlineSearch": { "placeholder": "搜尋目前會話", "ariaLabel": "搜尋目前會話", diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index 179ba715d..bd06086fe 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -1050,6 +1050,7 @@ "newWindow": "打開新窗口", "showHideWindow": "顯示/隱藏窗口", "newConversation": "新會話", + "quickSearch": "聚焦搜尋", "closeWindow": "關閉當前視窗" }, "rateLimit": { diff --git a/src/renderer/src/i18n/zh-TW/chat.json b/src/renderer/src/i18n/zh-TW/chat.json index e736cbe2f..779ec66aa 100644 --- a/src/renderer/src/i18n/zh-TW/chat.json +++ b/src/renderer/src/i18n/zh-TW/chat.json @@ -311,6 +311,20 @@ "subagents": { "label": "subagent" }, + "spotlight": { + "placeholder": "搜尋會話、Agent、設定與動作…", + "searching": "搜尋中…", + "emptyTitle": "沒有符合結果", + "emptyDescription": "試試其他關鍵字,或直接開啟最近會話。", + "hints": "↑↓ 選擇 · Enter 開啟 · Esc 關閉 · Home/End 跳轉", + "kind": { + "session": "會話", + "message": "訊息", + "agent": "Agent", + "setting": "設定", + "action": "動作" + } + }, "inlineSearch": { "placeholder": "搜尋目前會話", "ariaLabel": "搜尋目前會話", diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index 71059bd1d..9c34d8de6 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -1050,6 +1050,7 @@ "newWindow": "打開新窗口", "showHideWindow": "顯示/隱藏窗口", "newConversation": "新會話", + "quickSearch": "聚焦搜尋", "closeWindow": "關閉當前視窗" }, "rateLimit": { diff --git a/src/renderer/src/lib/chatSearch.ts b/src/renderer/src/lib/chatSearch.ts index 83453470f..28d406114 100644 --- a/src/renderer/src/lib/chatSearch.ts +++ b/src/renderer/src/lib/chatSearch.ts @@ -3,13 +3,38 @@ const ACTIVE_HIGHLIGHT_SELECTOR = '[data-chat-search-active]' export type ChatSearchMatch = HTMLElement -const isEditableElement = (element: HTMLElement | null): boolean => +const isIgnoredElement = (element: HTMLElement | null): boolean => Boolean( element?.closest( 'input, textarea, select, button, [contenteditable="true"], [data-chat-search-match]' ) ) +const isElementVisible = (element: HTMLElement | null): boolean => { + let currentElement = element + + while (currentElement) { + if (currentElement.hidden || currentElement.getAttribute('aria-hidden') === 'true') { + return false + } + + const style = window.getComputedStyle(currentElement) + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.visibility === 'collapse' || + style.contentVisibility === 'hidden' || + style.opacity === '0' + ) { + return false + } + + currentElement = currentElement.parentElement + } + + return true +} + const collectSearchableTextNodes = (root: ParentNode): Text[] => { if (typeof document === 'undefined') { return [] @@ -26,7 +51,7 @@ const collectSearchableTextNodes = (root: ParentNode): Text[] => { } const parentElement = node.parentElement - if (!parentElement || isEditableElement(parentElement)) { + if (!parentElement || isIgnoredElement(parentElement) || !isElementVisible(parentElement)) { return NodeFilter.FILTER_REJECT } diff --git a/src/renderer/src/stores/ui/pageRouter.ts b/src/renderer/src/stores/ui/pageRouter.ts index 1959c23de..01c0e3306 100644 --- a/src/renderer/src/stores/ui/pageRouter.ts +++ b/src/renderer/src/stores/ui/pageRouter.ts @@ -3,12 +3,16 @@ import { ref, computed } from 'vue' import { usePresenter } from '@/composables/usePresenter' export type PageRoute = { name: 'newThread' } | { name: 'chat'; sessionId: string } +type GoToNewThreadOptions = { + refresh?: boolean +} export const usePageRouterStore = defineStore('pageRouter', () => { const newAgentPresenter = usePresenter('newAgentPresenter') // --- State --- const route = ref({ name: 'newThread' }) + const newThreadRefreshKey = ref(0) const error = ref(null) // --- Actions --- @@ -31,8 +35,11 @@ export const usePageRouterStore = defineStore('pageRouter', () => { } } - function goToNewThread(): void { + function goToNewThread(options: GoToNewThreadOptions = {}): void { route.value = { name: 'newThread' } + if (options.refresh) { + newThreadRefreshKey.value += 1 + } } function goToChat(sessionId: string): void { @@ -46,6 +53,7 @@ export const usePageRouterStore = defineStore('pageRouter', () => { return { route, + newThreadRefreshKey, error, initialize, goToNewThread, diff --git a/src/renderer/src/stores/ui/spotlight.ts b/src/renderer/src/stores/ui/spotlight.ts index 6010892c7..d8846e73d 100644 --- a/src/renderer/src/stores/ui/spotlight.ts +++ b/src/renderer/src/stores/ui/spotlight.ts @@ -2,10 +2,10 @@ import { computed, ref, watch } from 'vue' import { defineStore } from 'pinia' import { useDebounceFn } from '@vueuse/core' import { usePresenter } from '@/composables/usePresenter' +import { useProviderStore } from '@/stores/providerStore' import { useAgentStore } from './agent' import { usePageRouterStore } from './pageRouter' import { useSessionStore } from './session' -import { SETTINGS_EVENTS } from '@/events' import { SETTINGS_NAVIGATION_ITEMS, type SettingsNavigationItem } from '@shared/settingsNavigation' import type { HistorySearchHit } from '@shared/presenter' @@ -32,6 +32,7 @@ export interface SpotlightItem { sessionId?: string messageId?: string routeName?: SettingsNavigationItem['routeName'] + routeParams?: Record actionId?: SpotlightActionId agentId?: string | null keywords?: string[] @@ -41,9 +42,6 @@ export interface SpotlightItem { const MAX_RESULTS = 12 // Debounce just enough to avoid spamming IPC while keeping the palette responsive. const SEARCH_DEBOUNCE_DELAY = 80 -// Retry once after the settings window boots so navigation is not lost during renderer startup. -const SETTINGS_WINDOW_NAVIGATION_RETRY_DELAY = 250 - const normalizeQuery = (value: string): string => value.trim().toLowerCase() const scoreTextMatch = (query: string, ...parts: Array): number => { @@ -130,11 +128,13 @@ const actionItems: Array<{ export const useSpotlightStore = defineStore('spotlight', () => { const newAgentPresenter = usePresenter('newAgentPresenter') const windowPresenter = usePresenter('windowPresenter') + const providerStore = useProviderStore() const sessionStore = useSessionStore() const agentStore = useAgentStore() const pageRouterStore = usePageRouterStore() const open = ref(false) + const activationKey = ref(0) const query = ref('') const results = ref([]) const activeIndex = ref(0) @@ -223,16 +223,44 @@ export const useSpotlightStore = defineStore('spotlight', () => { } } + const buildProviderMatches = (normalizedQuery: string): SpotlightItem[] => + providerStore.sortedProviders + .filter((provider) => provider.id !== 'acp') + .map((provider) => ({ + id: `setting:provider:${provider.id}`, + kind: 'setting' as const, + icon: 'lucide:cloud-cog', + title: provider.name, + subtitle: provider.apiType, + routeName: 'settings-provider' as const, + routeParams: { + providerId: provider.id + }, + keywords: [provider.id, provider.apiType, provider.baseUrl].filter( + (value): value is string => typeof value === 'string' && value.trim().length > 0 + ), + score: scoreTextMatch( + normalizedQuery, + provider.name, + provider.id, + provider.apiType, + provider.baseUrl + ) + })) + .filter((item) => item.score > 0) + const buildSettingMatches = (normalizedQuery: string): SpotlightItem[] => - SETTINGS_NAVIGATION_ITEMS.map((item) => ({ - id: `setting:${item.routeName}`, - kind: 'setting' as const, - icon: item.icon, - titleKey: item.titleKey, - routeName: item.routeName, - keywords: item.keywords, - score: scoreTextMatch(normalizedQuery, item.routeName, item.path, ...item.keywords) - })).filter((item) => item.score > 0) + SETTINGS_NAVIGATION_ITEMS.filter((item) => item.routeName !== 'settings-provider') + .map((item) => ({ + id: `setting:${item.routeName}`, + kind: 'setting' as const, + icon: item.icon, + titleKey: item.titleKey, + routeName: item.routeName, + keywords: item.keywords, + score: scoreTextMatch(normalizedQuery, item.routeName, item.path, ...item.keywords) + })) + .filter((item) => item.score > 0) const buildAgentMatches = (normalizedQuery: string): SpotlightItem[] => buildAgentItems() @@ -282,8 +310,11 @@ export const useSpotlightStore = defineStore('spotlight', () => { } results.value = sortResults([ - ...historyHits.map((hit) => toHistoryItem(hit, normalizedQuery)), + ...historyHits + .filter((hit) => hit.kind === 'session') + .map((hit) => toHistoryItem(hit, normalizedQuery)), ...buildAgentMatches(normalizedQuery), + ...buildProviderMatches(normalizedQuery), ...buildSettingMatches(normalizedQuery), ...buildActionMatches(normalizedQuery) ]) @@ -291,18 +322,17 @@ export const useSpotlightStore = defineStore('spotlight', () => { resetActiveIndex() }, SEARCH_DEBOUNCE_DELAY) - const navigateToSettings = async (routeName?: SettingsNavigationItem['routeName']) => { - const settingsWindowId = await windowPresenter.createSettingsWindow() - if (settingsWindowId == null || !routeName) { + const navigateToSettings = async ( + routeName?: SettingsNavigationItem['routeName'], + routeParams?: Record + ) => { + if (!routeName) { return } - const payload = { routeName } - windowPresenter.sendToWindow(settingsWindowId, SETTINGS_EVENTS.NAVIGATE, payload) - // The settings renderer may not be ready to process the first navigation message immediately. - window.setTimeout(() => { - windowPresenter.sendToWindow(settingsWindowId, SETTINGS_EVENTS.NAVIGATE, payload) - }, SETTINGS_WINDOW_NAVIGATION_RETRY_DELAY) + await windowPresenter.createSettingsWindow( + routeParams ? { routeName, params: routeParams } : { routeName } + ) } const setQuery = (value: string) => { @@ -312,7 +342,11 @@ export const useSpotlightStore = defineStore('spotlight', () => { return } - const normalizedQuery = normalizeQuery(value) + refreshOpenResults(value) + } + + const refreshOpenResults = (currentQuery: string) => { + const normalizedQuery = normalizeQuery(currentQuery) if (!normalizedQuery) { loading.value = false requestSeq.value += 1 @@ -323,12 +357,13 @@ export const useSpotlightStore = defineStore('spotlight', () => { loading.value = true const seq = ++requestSeq.value - void runSearch(value, seq) + void runSearch(currentQuery, seq) } const setOpen = (value: boolean) => { open.value = value if (value) { + activationKey.value += 1 setQuery(query.value) return } @@ -341,8 +376,7 @@ export const useSpotlightStore = defineStore('spotlight', () => { } const openSpotlight = () => { - open.value = true - setQuery(query.value) + setOpen(true) } const closeSpotlight = () => { @@ -374,7 +408,8 @@ export const useSpotlightStore = defineStore('spotlight', () => { const currentIndex = activeIndex.value < 0 ? 0 : activeIndex.value const nextIndex = - ((currentIndex + delta) % results.value.length + results.value.length) % results.value.length + (((currentIndex + delta) % results.value.length) + results.value.length) % + results.value.length activeIndex.value = nextIndex } @@ -410,7 +445,7 @@ export const useSpotlightStore = defineStore('spotlight', () => { } if (item.kind === 'setting') { - await navigateToSettings(item.routeName) + await navigateToSettings(item.routeName, item.routeParams) return } @@ -419,7 +454,7 @@ export const useSpotlightStore = defineStore('spotlight', () => { if (sessionStore.hasActiveSession) { await sessionStore.closeSession() } else { - pageRouterStore.goToNewThread() + pageRouterStore.goToNewThread({ refresh: true }) } return case 'open-settings': @@ -449,17 +484,32 @@ export const useSpotlightStore = defineStore('spotlight', () => { } watch( - () => [sessionStore.sessions.length, agentStore.enabledAgents.length, open.value, query.value] as const, - ([, , isOpen, currentQuery]) => { - if (isOpen && !normalizeQuery(currentQuery)) { - results.value = buildDefaultResults() - resetActiveIndex() + () => + [ + sessionStore.sessions.map( + (session) => `${session.id}:${session.updatedAt}:${session.title}` + ), + providerStore.sortedProviders.map( + (provider) => + `${provider.id}:${provider.name}:${provider.apiType}:${provider.baseUrl}:${provider.enable}` + ), + agentStore.enabledAgents.map( + (agent) => + `${agent.id}:${agent.name}:${agent.description ?? ''}:${agent.type}:${agent.agentType ?? ''}` + ) + ] as const, + () => { + if (!open.value) { + return } + + refreshOpenResults(query.value) } ) return { open, + activationKey, query, results, activeIndex, diff --git a/src/shared/settingsNavigation.ts b/src/shared/settingsNavigation.ts index 1b5781323..d5600c619 100644 --- a/src/shared/settingsNavigation.ts +++ b/src/shared/settingsNavigation.ts @@ -23,6 +23,12 @@ export interface SettingsNavigationItem { keywords: string[] } +export interface SettingsNavigationPayload { + routeName: SettingsNavigationItem['routeName'] + params?: Record + section?: string +} + export const SETTINGS_NAVIGATION_ITEMS: SettingsNavigationItem[] = [ { routeName: 'settings-common', @@ -153,3 +159,34 @@ export const SETTINGS_NAVIGATION_ITEMS: SettingsNavigationItem[] = [ keywords: ['about', 'version', 'info', '关于', '版本'] } ] + +export const resolveSettingsNavigationPath = ( + routeName: SettingsNavigationItem['routeName'], + params?: Record +): string => { + const item = SETTINGS_NAVIGATION_ITEMS.find( + (navigationItem) => navigationItem.routeName === routeName + ) + if (!item) { + return '/common' + } + + const resolvedSegments = item.path + .split('/') + .filter((segment) => segment.length > 0) + .flatMap((segment) => { + if (!segment.startsWith(':')) { + return [segment] + } + + const key = segment.slice(1).replace(/\?$/, '') + const value = params?.[key]?.trim() + if (value) { + return [encodeURIComponent(value)] + } + + return segment.endsWith('?') ? [] : [key] + }) + + return `/${resolvedSegments.join('/')}` +} diff --git a/src/shared/types/presenters/legacy.presenters.d.ts b/src/shared/types/presenters/legacy.presenters.d.ts index b1e9157b5..bca328873 100644 --- a/src/shared/types/presenters/legacy.presenters.d.ts +++ b/src/shared/types/presenters/legacy.presenters.d.ts @@ -261,7 +261,9 @@ export interface IWindowPresenter { minimize(windowId: number): void maximize(windowId: number): void close(windowId: number): void - createSettingsWindow(): Promise + createSettingsWindow( + navigation?: import('@shared/settingsNavigation').SettingsNavigationPayload + ): Promise closeSettingsWindow(): void getSettingsWindowId(): number | null setPendingSettingsProviderInstall( diff --git a/test/main/presenter/newAgentPresenter/newAgentPresenter.test.ts b/test/main/presenter/newAgentPresenter/newAgentPresenter.test.ts index 573ab5fc4..953ad0c1e 100644 --- a/test/main/presenter/newAgentPresenter/newAgentPresenter.test.ts +++ b/test/main/presenter/newAgentPresenter/newAgentPresenter.test.ts @@ -185,7 +185,9 @@ function createMockSqlitePresenter() { sessionId: 'session-1', title: 'Release checklist', role: 'assistant', - content: JSON.stringify([{ type: 'text', content: 'pnpm run build still fails on arm64' }]), + content: JSON.stringify([ + { type: 'text', content: 'pnpm run build still fails on arm64' } + ]), updatedAt: 100 } ] diff --git a/test/main/presenter/windowPresenter.test.ts b/test/main/presenter/windowPresenter.test.ts index de19c022f..908dd25fd 100644 --- a/test/main/presenter/windowPresenter.test.ts +++ b/test/main/presenter/windowPresenter.test.ts @@ -131,4 +131,22 @@ describe('WindowPresenter settings navigation queue', () => { expect(presenter.consumePendingSettingsProviderInstall()).toEqual(secondPreview) expect(presenter.consumePendingSettingsProviderInstall()).toBeNull() }) + + it('keeps the settings window ready during same-document navigation', async () => { + const { WindowPresenter } = await import('@/presenter/windowPresenter') + const presenter = new WindowPresenter({ + getContentProtectionEnabled: vi.fn(() => false) + } as any) + + ;(presenter as any).settingsWindow = { + id: 9 + } + ;(presenter as any).settingsWindowReady = true + + ;(presenter as any).handleSettingsWindowNavigationStart(9, true, true) + expect((presenter as any).settingsWindowReady).toBe(true) + + ;(presenter as any).handleSettingsWindowNavigationStart(9, true, false) + expect((presenter as any).settingsWindowReady).toBe(false) + }) }) diff --git a/test/main/shared/settingsNavigation.test.ts b/test/main/shared/settingsNavigation.test.ts new file mode 100644 index 000000000..cc19e5a2c --- /dev/null +++ b/test/main/shared/settingsNavigation.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { resolveSettingsNavigationPath } from '@shared/settingsNavigation' + +describe('settings navigation helpers', () => { + it('resolves direct settings routes', () => { + expect(resolveSettingsNavigationPath('settings-mcp')).toBe('/mcp') + }) + + it('resolves provider routes with params', () => { + expect( + resolveSettingsNavigationPath('settings-provider', { + providerId: 'openai' + }) + ).toBe('/provider/openai') + }) + + it('resolves optional provider params without a provider id', () => { + expect(resolveSettingsNavigationPath('settings-provider')).toBe('/provider') + }) +}) diff --git a/test/renderer/components/App.startup.test.ts b/test/renderer/components/App.startup.test.ts index d4f5813e1..25a489539 100644 --- a/test/renderer/components/App.startup.test.ts +++ b/test/renderer/components/App.startup.test.ts @@ -1,7 +1,7 @@ import { mount, flushPromises } from '@vue/test-utils' import { reactive, ref } from 'vue' import { afterEach, describe, expect, it, vi } from 'vitest' -import { DEEPLINK_EVENTS } from '@/events' +import { DEEPLINK_EVENTS, SHORTCUT_EVENTS } from '@/events' const DEV_WELCOME_OVERRIDE_KEY = '__deepchat_dev_force_welcome' @@ -48,6 +48,21 @@ const mountApp = async (options?: { const pageRouterStore = { goToNewThread: vi.fn() } + const spotlightStore = { + open: false, + query: '', + results: [] as unknown[], + activeIndex: -1, + loading: false, + openSpotlight: vi.fn(), + closeSpotlight: vi.fn(), + setQuery: vi.fn(), + setActiveItem: vi.fn(), + moveActiveItem: vi.fn(), + executeItem: vi.fn(), + executeActiveItem: vi.fn(), + toggleSpotlight: vi.fn() + } const agentStore = { setSelectedAgent: vi.fn() } @@ -116,6 +131,9 @@ const mountApp = async (options?: { vi.doMock('@/stores/ui/pageRouter', () => ({ usePageRouterStore: () => pageRouterStore })) + vi.doMock('@/stores/ui/spotlight', () => ({ + useSpotlightStore: () => spotlightStore + })) vi.doMock('@/components/use-toast', () => ({ useToast: () => ({ toast @@ -196,7 +214,8 @@ const mountApp = async (options?: { agentStore, draftStore, sessionStore, - ipcOn + ipcOn, + spotlightStore } } @@ -213,7 +232,7 @@ describe('App startup welcome flow', () => { expect(configPresenter.getSetting).toHaveBeenCalledWith('init_complete') expect(router.replace).toHaveBeenCalledWith({ name: 'welcome' }) - }) + }, 10000) it('redirects welcome back to chat when init is complete', async () => { const { router, configPresenter, route } = await mountApp({ @@ -274,4 +293,22 @@ describe('App startup welcome flow', () => { expect(sessionStore.closeSession).toHaveBeenCalledTimes(1) expect(pageRouterStore.goToNewThread).not.toHaveBeenCalled() }) + + it('opens spotlight from the global shortcut event', async () => { + const { ipcOn, spotlightStore } = await mountApp({ + initComplete: true, + routeName: 'chat' + }) + + const shortcutHandler = ipcOn.mock.calls.find( + ([eventName]: [string]) => eventName === SHORTCUT_EVENTS.TOGGLE_SPOTLIGHT + )?.[1] + + expect(shortcutHandler).toBeTypeOf('function') + + shortcutHandler?.() + + expect(spotlightStore.openSpotlight).toHaveBeenCalledTimes(1) + expect(spotlightStore.toggleSpotlight).not.toHaveBeenCalled() + }) }) diff --git a/test/renderer/components/ChatPage.test.ts b/test/renderer/components/ChatPage.test.ts index 1ee8a27f5..ef0bd9d76 100644 --- a/test/renderer/components/ChatPage.test.ts +++ b/test/renderer/components/ChatPage.test.ts @@ -251,6 +251,12 @@ const setup = async (options: SetupOptions = {}) => { } }, emits: ['update:modelValue', 'previous', 'next', 'close'], + setup(_, { expose }) { + expose({ + focusInput: vi.fn(), + selectInput: vi.fn() + }) + }, template: '
' }) @@ -490,8 +496,6 @@ describe('ChatPage', () => { } }) - await flushPromises() - vi.runAllTimers() await flushPromises() expect(wrapper.find('[data-message-id="m1"]').classes()).toContain('message-highlight') diff --git a/test/renderer/components/ModelProviderSettings.test.ts b/test/renderer/components/ModelProviderSettings.test.ts index 722be8a83..d154cf490 100644 --- a/test/renderer/components/ModelProviderSettings.test.ts +++ b/test/renderer/components/ModelProviderSettings.test.ts @@ -24,8 +24,20 @@ const draggableStub = defineComponent({ '
' }) -const setup = async () => { +const setup = async (options?: { + routeProviderId?: string | undefined + providers?: Array<{ + id: string + name: string + apiType: string + apiKey: string + baseUrl: string + enable: boolean + }> +}) => { vi.resetModules() + const routeProviderId = + options && 'routeProviderId' in options ? options.routeProviderId : 'anthropic' const provider = { id: 'anthropic', @@ -35,9 +47,10 @@ const setup = async () => { baseUrl: 'https://api.anthropic.com', enable: true } + const providers = options?.providers ?? [provider] const providerStore = reactive({ - providers: [provider], - sortedProviders: [provider], + providers, + sortedProviders: providers, refreshProviders: vi.fn().mockResolvedValue(undefined), updateProviderConfig: vi.fn().mockResolvedValue(undefined), updateProviderApi: vi.fn().mockResolvedValue(undefined), @@ -71,7 +84,9 @@ const setup = async () => { useLanguageStore: () => ({ dir: 'ltr' }) })) vi.doMock('vue-router', () => ({ - useRoute: () => ({ params: { providerId: 'anthropic' } }), + useRoute: () => ({ + params: routeProviderId ? { providerId: routeProviderId } : {} + }), useRouter: () => router })) vi.doMock('@vueuse/core', () => ({ @@ -147,4 +162,36 @@ describe('ModelProviderSettings', () => { } }) }) + + it('skips ACP when auto-selecting the default provider settings view', async () => { + const { router } = await setup({ + routeProviderId: undefined, + providers: [ + { + id: 'acp', + name: 'ACP', + apiType: 'openai', + apiKey: '', + baseUrl: '', + enable: true + }, + { + id: 'anthropic', + name: 'Anthropic', + apiType: 'anthropic', + apiKey: 'test-key', + baseUrl: 'https://api.anthropic.com', + enable: true + } + ] + }) + + expect(router.push).toHaveBeenCalledWith({ + name: 'settings-provider', + params: { + providerId: 'anthropic' + } + }) + expect(router.replace).not.toHaveBeenCalledWith({ name: 'settings-acp' }) + }) }) diff --git a/test/renderer/components/SettingsApp.test.ts b/test/renderer/components/SettingsApp.test.ts index 454268bc6..5fa16a8d5 100644 --- a/test/renderer/components/SettingsApp.test.ts +++ b/test/renderer/components/SettingsApp.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' -import { defineComponent, ref } from 'vue' +import { defineComponent, reactive, ref } from 'vue' import { DEEPLINK_EVENTS, SETTINGS_EVENTS } from '@/events' afterEach(() => { @@ -198,16 +198,33 @@ describe('Settings App', () => { } }) + await flushPromises() await flushPromises() expect(isReady).toHaveBeenCalledTimes(1) expect(ipcSend).toHaveBeenCalledWith(SETTINGS_EVENTS.READY) - }) + }, 15000) it('navigates to the requested settings route when a navigate event arrives', async () => { vi.resetModules() - const push = vi.fn().mockResolvedValue(undefined) + const route = reactive({ + name: 'settings-common', + query: {}, + params: {}, + path: '/common' + }) + const currentRoute = ref(route) + const push = vi.fn().mockImplementation(async (target: { name?: string; params?: any }) => { + if (!target?.name) { + return + } + + route.name = target.name + route.params = target.params ?? {} + route.path = target.name === 'settings-deepchat-agents' ? '/deepchat-agents' : '/common' + currentRoute.value = route + }) const isReady = vi.fn().mockResolvedValue(undefined) const ipcOn = vi.fn() const ipcRemoveListener = vi.fn() @@ -224,7 +241,6 @@ describe('Settings App', () => { } vi.doMock('vue-router', () => { - const currentRoute = ref({ name: 'settings-common', query: {}, params: {}, path: '/common' }) const router = { hasRoute: vi.fn((routeName: string) => routeName === 'settings-deepchat-agents'), isReady, @@ -251,7 +267,7 @@ describe('Settings App', () => { return { useRouter: () => router, - useRoute: () => currentRoute.value, + useRoute: () => route, RouterView: { name: 'RouterView', template: '
' @@ -414,7 +430,241 @@ describe('Settings App', () => { await navigateHandler?.({}, { routeName: 'settings-deepchat-agents' }) - expect(push).toHaveBeenCalledWith({ name: 'settings-deepchat-agents' }) + expect(push).toHaveBeenCalledWith({ + name: 'settings-deepchat-agents', + params: undefined + }) + }, 15000) + + it('reuses settings-provider route params when a provider navigate event arrives', async () => { + vi.resetModules() + + const push = vi.fn().mockResolvedValue(undefined) + const isReady = vi.fn().mockResolvedValue(undefined) + const ipcOn = vi.fn() + const ipcRemoveListener = vi.fn() + const ipcRemoveAllListeners = vi.fn() + const ipcSend = vi.fn() + + ;(window as any).electron = { + ipcRenderer: { + on: ipcOn, + removeListener: ipcRemoveListener, + removeAllListeners: ipcRemoveAllListeners, + send: ipcSend + } + } + + vi.doMock('vue-router', () => { + const currentRoute = ref({ + name: 'settings-provider', + query: {}, + params: { providerId: 'deepseek' }, + path: '/provider/deepseek' + }) + const router = { + hasRoute: vi.fn((routeName: string) => routeName === 'settings-provider'), + isReady, + push, + replace: vi.fn().mockResolvedValue(undefined), + getRoutes: vi.fn(() => [ + { + path: '/common', + name: 'settings-common', + meta: { titleKey: 'routes.settings-common', icon: 'lucide:bolt', position: 1 } + }, + { + path: '/provider/:providerId?', + name: 'settings-provider', + meta: { + titleKey: 'routes.settings-provider', + icon: 'lucide:cloud-cog', + position: 3 + } + } + ]), + currentRoute + } + + return { + useRouter: () => router, + useRoute: () => currentRoute.value, + RouterView: { + name: 'RouterView', + template: '
' + } + } + }) + + vi.doMock('../../../src/renderer/src/composables/usePresenter', () => ({ + usePresenter: (name: string) => { + if (name === 'devicePresenter') { + return { + getDeviceInfo: vi.fn().mockResolvedValue({ platform: 'darwin' }) + } + } + if (name === 'windowPresenter') { + return { + closeSettingsWindow: vi.fn(), + consumePendingSettingsProviderInstall: vi.fn().mockResolvedValue(null) + } + } + if (name === 'configPresenter') { + return { + getLanguage: vi.fn().mockResolvedValue('zh-CN') + } + } + return {} + } + })) + vi.doMock('../../../src/renderer/src/stores/uiSettingsStore', () => ({ + useUiSettingsStore: () => ({ + fontSizeClass: 'text-base', + loadSettings: vi.fn().mockResolvedValue(undefined) + }) + })) + vi.doMock('../../../src/renderer/src/stores/language', () => ({ + useLanguageStore: () => ({ + language: 'zh-CN', + dir: 'ltr' + }) + })) + vi.doMock('../../../src/renderer/src/stores/modelCheck', () => ({ + useModelCheckStore: () => ({ + isDialogOpen: false, + currentProviderId: null, + closeDialog: vi.fn() + }) + })) + vi.doMock('../../../src/renderer/src/stores/theme', () => ({ + useThemeStore: () => ({ + themeMode: 'light', + isDark: false + }) + })) + vi.doMock('../../../src/renderer/src/stores/providerStore', () => ({ + useProviderStore: () => ({ + providers: [], + initialize: vi.fn().mockResolvedValue(undefined) + }) + })) + vi.doMock('../../../src/renderer/src/stores/providerDeeplinkImport', () => ({ + useProviderDeeplinkImportStore: () => ({ + preview: null, + previewToken: 0, + openPreview: vi.fn(), + clearPreview: vi.fn() + }) + })) + vi.doMock('../../../src/renderer/src/stores/modelStore', () => ({ + useModelStore: () => ({ + initialize: vi.fn().mockResolvedValue(undefined) + }) + })) + vi.doMock('../../../src/renderer/src/stores/ollamaStore', () => ({ + useOllamaStore: () => ({ + initialize: vi.fn().mockResolvedValue(undefined) + }) + })) + vi.doMock('../../../src/renderer/src/stores/mcp', () => ({ + useMcpStore: () => ({ + mcpEnabled: false, + setMcpEnabled: vi.fn().mockResolvedValue(undefined), + setMcpInstallCache: vi.fn() + }) + })) + vi.doMock('../../../src/renderer/src/lib/storeInitializer', () => ({ + useMcpInstallDeeplinkHandler: () => ({ + setup: vi.fn(), + cleanup: vi.fn() + }) + })) + vi.doMock('../../../src/renderer/src/composables/useFontManager', () => ({ + useFontManager: () => ({ + setupFontListener: vi.fn() + }) + })) + vi.doMock('../../../src/renderer/src/composables/useDeviceVersion', () => ({ + useDeviceVersion: () => ({ + isMacOS: ref(false), + isWinMacOS: true + }) + })) + vi.doMock('@vueuse/core', () => ({ + useTitle: () => ref('') + })) + vi.doMock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + locale: ref('zh-CN') + }) + })) + vi.doMock('@iconify/vue', () => ({ + Icon: { + name: 'Icon', + template: '' + } + })) + vi.doMock('@/components/use-toast', () => ({ + useToast: () => ({ + toast: vi.fn(() => ({ dismiss: vi.fn() })) + }) + })) + + const SettingsApp = (await import('../../../src/renderer/settings/App.vue')).default + mount(SettingsApp, { + global: { + stubs: { + Button: true, + RouterView: true, + CloseIcon: true, + ModelCheckDialog: defineComponent({ + name: 'ModelCheckDialog', + props: { + open: { type: Boolean, default: false }, + providerId: { type: null, default: null } + }, + template: '
' + }), + ProviderDeeplinkImportDialog: defineComponent({ + name: 'ProviderDeeplinkImportDialog', + props: { + open: { type: Boolean, default: false }, + preview: { type: null, default: null } + }, + template: '
' + }), + Toaster: true, + Icon: true + } + } + }) + + await Promise.resolve() + await Promise.resolve() + + const navigateHandler = ipcOn.mock.calls.find( + ([eventName]: [string]) => eventName === SETTINGS_EVENTS.NAVIGATE + )?.[1] + + expect(navigateHandler).toBeTypeOf('function') + + await navigateHandler?.( + {}, + { + routeName: 'settings-provider', + params: { + providerId: 'openai' + } + } + ) + + expect(push).toHaveBeenCalledWith({ + name: 'settings-provider', + params: { + providerId: 'openai' + } + }) }) it('navigates to provider settings and stores provider deeplink previews', async () => { diff --git a/test/renderer/components/SpotlightOverlay.test.ts b/test/renderer/components/SpotlightOverlay.test.ts new file mode 100644 index 000000000..7897fcb53 --- /dev/null +++ b/test/renderer/components/SpotlightOverlay.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest' +import { defineComponent, nextTick, reactive } from 'vue' +import { mount } from '@vue/test-utils' + +const setup = async () => { + vi.resetModules() + + const resultItem = { + id: 'session:1', + kind: 'session' as const, + icon: 'lucide:message-square', + title: 'DeepChat Session', + subtitle: '/workspace/demo', + score: 100, + sessionId: 'session-1' + } + + const spotlightStore = reactive({ + open: true, + activationKey: 1, + query: '', + results: [resultItem], + activeIndex: 0, + loading: false, + closeSpotlight: vi.fn(), + setQuery: vi.fn(), + setActiveItem: vi.fn(), + moveActiveItem: vi.fn(), + executeItem: vi.fn(), + executeActiveItem: vi.fn() + }) + + vi.doMock('@/stores/ui/spotlight', () => ({ + useSpotlightStore: () => spotlightStore + })) + + vi.doMock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key + }) + })) + + vi.doMock('@iconify/vue', () => ({ + Icon: defineComponent({ + name: 'Icon', + props: { + icon: { + type: String, + default: '' + } + }, + template: '' + }) + })) + + const SpotlightOverlay = (await import('@/components/spotlight/SpotlightOverlay.vue')).default + document.body.innerHTML = '' + const wrapper = mount(SpotlightOverlay, { + attachTo: document.body + }) + + return { + wrapper, + spotlightStore, + resultItem + } +} + +describe('SpotlightOverlay', () => { + it('marks the overlay as a no-drag region', async () => { + const { wrapper } = await setup() + + expect(wrapper.classes()).toContain('window-no-drag-region') + expect(wrapper.find('.window-no-drag-region').exists()).toBe(true) + }) + + it('forwards input changes and immediate mouse selections to the spotlight store', async () => { + const { wrapper, spotlightStore, resultItem } = await setup() + + await wrapper.get('input').setValue('deep') + expect(spotlightStore.setQuery).toHaveBeenCalledWith('deep') + + await wrapper.get('button').trigger('mousedown', { button: 0 }) + expect(spotlightStore.executeItem).toHaveBeenCalledWith(resultItem) + }) + + it('refocuses the search input when spotlight is activated again', async () => { + const { wrapper, spotlightStore } = await setup() + + const input = wrapper.get('input').element as HTMLInputElement + input.blur() + + spotlightStore.activationKey += 1 + await nextTick() + await nextTick() + + expect(document.activeElement).toBe(input) + }) +}) diff --git a/test/renderer/components/WindowSideBar.test.ts b/test/renderer/components/WindowSideBar.test.ts index c9f2d5161..eb414a7a2 100644 --- a/test/renderer/components/WindowSideBar.test.ts +++ b/test/renderer/components/WindowSideBar.test.ts @@ -73,6 +73,9 @@ const setup = async (options: SetupOptions = {}) => { const themeStore = reactive({ isDark: false }) + const pageRouterStore = reactive({ + goToNewThread: vi.fn() + }) const spotlightStore = reactive({ open: false, toggleSpotlight: vi.fn(() => { @@ -129,6 +132,9 @@ const setup = async (options: SetupOptions = {}) => { vi.doMock('@/stores/theme', () => ({ useThemeStore: () => themeStore })) + vi.doMock('@/stores/ui/pageRouter', () => ({ + usePageRouterStore: () => pageRouterStore + })) vi.doMock('@/stores/ui/spotlight', () => ({ useSpotlightStore: () => spotlightStore })) @@ -214,7 +220,8 @@ const setup = async (options: SetupOptions = {}) => { sessionStore, windowPresenter, remoteControlPresenter, - spotlightStore + spotlightStore, + pageRouterStore } } @@ -229,6 +236,16 @@ describe('WindowSideBar agent switch', () => { expect(operations).toEqual(['close', 'set:acp-a']) }, 10000) + it('refreshes the new thread page when starting a new chat without an active session', async () => { + const { wrapper, pageRouterStore, sessionStore } = await setup() + sessionStore.hasActiveSession = false + + ;(wrapper.vm as any).handleNewChat() + + expect(sessionStore.closeSession).not.toHaveBeenCalled() + expect(pageRouterStore.goToNewThread).toHaveBeenCalledWith({ refresh: true }) + }) + it('renders pinned sessions outside grouped sections', async () => { const { wrapper } = await setup({ pinnedSessions: [ diff --git a/test/renderer/components/chat/ChatSearchBar.test.ts b/test/renderer/components/chat/ChatSearchBar.test.ts new file mode 100644 index 000000000..aa4ce2137 --- /dev/null +++ b/test/renderer/components/chat/ChatSearchBar.test.ts @@ -0,0 +1,44 @@ +import { mount } from '@vue/test-utils' +import { defineComponent } from 'vue' +import { describe, expect, it, vi } from 'vitest' +import ChatSearchBar from '@/components/chat/ChatSearchBar.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key + }) +})) + +describe('ChatSearchBar', () => { + it('focuses and selects the input through the exposed API', async () => { + const wrapper = mount(ChatSearchBar, { + attachTo: document.body, + props: { + modelValue: 'hello', + activeMatch: 0, + totalMatches: 1 + }, + global: { + stubs: { + Icon: true, + Button: defineComponent({ + name: 'Button', + template: '' + }) + } + } + }) + + const input = wrapper.get('input').element as HTMLInputElement + input.setSelectionRange(0, 0) + + ;(wrapper.vm as { selectInput: () => void }).selectInput() + await wrapper.vm.$nextTick() + + expect(document.activeElement).toBe(input) + expect(input.selectionStart).toBe(0) + expect(input.selectionEnd).toBe(input.value.length) + + wrapper.unmount() + }) +}) diff --git a/test/renderer/lib/chatSearch.test.ts b/test/renderer/lib/chatSearch.test.ts index 669dac9de..f2ffa78fc 100644 --- a/test/renderer/lib/chatSearch.test.ts +++ b/test/renderer/lib/chatSearch.test.ts @@ -47,4 +47,23 @@ describe('chatSearch', () => { expect(container.querySelectorAll('mark[data-chat-search-match="true"]')).toHaveLength(0) expect(container.textContent?.replace(/\s+/g, ' ').trim()).toBe('Hello world, hello DeepChat') }) + + it('ignores matches inside hidden message content', () => { + const container = document.createElement('div') + container.innerHTML = ` +
+

hi

+

Hi there!

+
+

thinking hi

+
+
+ ` + + const matches = applyChatSearchHighlights(container, 'hi') + + expect(matches).toHaveLength(2) + expect(matches.map((match) => match.textContent)).toEqual(['hi', 'Hi']) + expect(container.querySelectorAll('mark[data-chat-search-match="true"]')).toHaveLength(2) + }) }) diff --git a/test/renderer/stores/pageRouter.test.ts b/test/renderer/stores/pageRouter.test.ts index feac0a13e..07c184e2e 100644 --- a/test/renderer/stores/pageRouter.test.ts +++ b/test/renderer/stores/pageRouter.test.ts @@ -67,6 +67,20 @@ describe('pageRouter.initialize', () => { expect(store.route.value).toEqual({ name: 'newThread' }) }) + it('can force-refresh the new thread view', async () => { + const { store } = await setupStore({ + activeNewSession: null + }) + + expect(store.newThreadRefreshKey.value).toBe(0) + + store.goToNewThread({ refresh: true }) + store.goToNewThread({ refresh: true }) + + expect(store.route.value).toEqual({ name: 'newThread' }) + expect(store.newThreadRefreshKey.value).toBe(2) + }) + it('falls back to new thread when active session lookup fails', async () => { vi.resetModules() diff --git a/test/renderer/stores/spotlight.test.ts b/test/renderer/stores/spotlight.test.ts new file mode 100644 index 000000000..5f8d805a3 --- /dev/null +++ b/test/renderer/stores/spotlight.test.ts @@ -0,0 +1,298 @@ +import { flushPromises } from '@vue/test-utils' +import { reactive } from 'vue' +import { describe, expect, it, vi } from 'vitest' + +const setupStore = async (options?: { + hasActiveSession?: boolean + historyHits?: Array> +}) => { + vi.resetModules() + + const newAgentPresenter = { + searchHistory: vi.fn().mockResolvedValue(options?.historyHits ?? []) + } + const windowPresenter = { + createSettingsWindow: vi.fn().mockResolvedValue(9), + sendToWindow: vi.fn(), + openOrFocusSettingsWindow: vi.fn() + } + const providerStore = reactive({ + sortedProviders: [ + { + id: 'openai', + name: 'OpenAI', + apiType: 'openai', + baseUrl: 'https://api.openai.com/v1' + }, + { + id: 'acp', + name: 'ACP', + apiType: 'acp', + baseUrl: 'https://acp.example.com' + } + ] + }) + const sessionStore = reactive({ + sessions: [], + hasActiveSession: options?.hasActiveSession ?? false, + closeSession: vi.fn().mockResolvedValue(undefined), + selectSession: vi.fn() + }) + const agentStore = reactive({ + enabledAgents: [], + setSelectedAgent: vi.fn() + }) + const pageRouterStore = reactive({ + goToNewThread: vi.fn() + }) + + vi.doMock('pinia', () => ({ + defineStore: (_id: string, setup: () => unknown) => setup + })) + + vi.doMock('@vueuse/core', () => ({ + useDebounceFn: (fn: (...args: unknown[]) => unknown) => fn + })) + + vi.doMock('@/composables/usePresenter', () => ({ + usePresenter: (name: string) => { + if (name === 'newAgentPresenter') { + return newAgentPresenter + } + + if (name === 'windowPresenter') { + return windowPresenter + } + + return {} + } + })) + + vi.doMock('@/stores/ui/session', () => ({ + useSessionStore: () => sessionStore + })) + + vi.doMock('@/stores/ui/agent', () => ({ + useAgentStore: () => agentStore + })) + + vi.doMock('@/stores/ui/pageRouter', () => ({ + usePageRouterStore: () => pageRouterStore + })) + + vi.doMock('@/stores/providerStore', () => ({ + useProviderStore: () => providerStore + })) + + vi.doMock('@shared/settingsNavigation', () => ({ + SETTINGS_NAVIGATION_ITEMS: [] + })) + + const { useSpotlightStore } = await import('@/stores/ui/spotlight') + const store = useSpotlightStore() + + return { + store, + providerStore, + sessionStore, + pageRouterStore, + windowPresenter + } +} + +describe('spotlightStore new-chat action', () => { + it('bumps the activation key each time spotlight is opened', async () => { + const { store } = await setupStore() + + expect(store.activationKey.value).toBe(0) + + store.openSpotlight() + expect(store.open.value).toBe(true) + expect(store.activationKey.value).toBe(1) + + store.openSpotlight() + expect(store.activationKey.value).toBe(2) + }) + + it('refreshes the new thread page when no session is active', async () => { + const { store, sessionStore, pageRouterStore } = await setupStore({ + hasActiveSession: false + }) + + await store.executeItem({ + id: 'action:new-chat', + kind: 'action', + icon: 'lucide:square-pen', + actionId: 'new-chat', + titleKey: 'common.newChat', + score: 1 + }) + + expect(sessionStore.closeSession).not.toHaveBeenCalled() + expect(pageRouterStore.goToNewThread).toHaveBeenCalledWith({ refresh: true }) + }) + + it('closes the active session before opening a new chat', async () => { + const { store, sessionStore, pageRouterStore } = await setupStore({ + hasActiveSession: true + }) + + await store.executeItem({ + id: 'action:new-chat', + kind: 'action', + icon: 'lucide:square-pen', + actionId: 'new-chat', + titleKey: 'common.newChat', + score: 1 + }) + + expect(sessionStore.closeSession).toHaveBeenCalledTimes(1) + expect(pageRouterStore.goToNewThread).not.toHaveBeenCalled() + }) + + it('returns provider-level results for provider queries', async () => { + const { store } = await setupStore() + + store.setOpen(true) + store.setQuery('openai') + await flushPromises() + + expect(store.results.value).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'setting:provider:openai', + routeName: 'settings-provider', + routeParams: { + providerId: 'openai' + }, + title: 'OpenAI' + }) + ]) + ) + expect(store.results.value).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'setting:provider:acp' + }) + ]) + ) + }) + + it('searches sessions only and excludes message hits', async () => { + const { store } = await setupStore({ + historyHits: [ + { + kind: 'session', + sessionId: 'session-1', + title: 'OpenAI setup', + projectDir: '/tmp/demo', + updatedAt: 10 + }, + { + kind: 'message', + sessionId: 'session-1', + messageId: 'message-1', + title: 'OpenAI key snippet', + snippet: 'Here is the message content', + updatedAt: 20 + } + ] + }) + + store.setOpen(true) + store.setQuery('openai') + await flushPromises() + + expect(store.results.value).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'session:session-1', + kind: 'session', + title: 'OpenAI setup' + }) + ]) + ) + expect(store.results.value).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'message' + }) + ]) + ) + }) + + it('reuses settings-provider route params when opening a provider result', async () => { + const { store, windowPresenter } = await setupStore() + + await store.executeItem({ + id: 'setting:provider:openai', + kind: 'setting', + icon: 'lucide:cloud-cog', + title: 'OpenAI', + routeName: 'settings-provider', + routeParams: { + providerId: 'openai' + }, + score: 320 + }) + + expect(windowPresenter.createSettingsWindow).toHaveBeenCalledTimes(1) + expect(windowPresenter.createSettingsWindow).toHaveBeenCalledWith({ + routeName: 'settings-provider', + params: { + providerId: 'openai' + } + }) + expect(windowPresenter.sendToWindow).not.toHaveBeenCalled() + }) + + it('reruns an active query when provider matches change', async () => { + const { store, providerStore } = await setupStore() + + providerStore.sortedProviders = [ + { + id: 'acp', + name: 'ACP', + apiType: 'acp', + baseUrl: 'https://acp.example.com' + } + ] + + store.setOpen(true) + store.setQuery('openai') + await flushPromises() + + expect(store.results.value).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'setting:provider:openai' + }) + ]) + ) + + providerStore.sortedProviders = [ + ...providerStore.sortedProviders, + { + id: 'openai', + name: 'OpenAI', + apiType: 'openai', + baseUrl: 'https://api.openai.com/v1', + enable: false + } + ] + + await flushPromises() + await flushPromises() + + expect(store.results.value).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'setting:provider:openai', + routeParams: { + providerId: 'openai' + } + }) + ]) + ) + }) +})