diff --git a/desktop/src/components/chat/ThinkingBlock.tsx b/desktop/src/components/chat/ThinkingBlock.tsx index ba602738..d35c5254 100644 --- a/desktop/src/components/chat/ThinkingBlock.tsx +++ b/desktop/src/components/chat/ThinkingBlock.tsx @@ -1,14 +1,57 @@ import { useState, useEffect, useMemo, useRef } from 'react' import { useTranslation } from '../../i18n' +import { useSettingsStore } from '../../stores/settingsStore' import { MarkdownRenderer } from '../markdown/MarkdownRenderer' +function ThinkingBrainIcon({ isActive }: { isActive: boolean }) { + if (isActive) { + return ( + + ) + } + return ( + + ) +} + export function ThinkingBlock({ content, isActive = false }: { content: string; isActive?: boolean }) { const t = useTranslation() - const [expanded, setExpanded] = useState(true) + const thinkingAutoCollapse = useSettingsStore((s) => s.thinkingAutoCollapse) + const [expanded, setExpanded] = useState(!thinkingAutoCollapse) const contentRef = useRef(null) const displayContent = useMemo(() => content.replace(/\r\n?/g, '\n').trimEnd(), [content]) const hasDisplayContent = displayContent.trim().length > 0 + // Auto-collapse when thinking finishes (isActive transitions from true to false) + useEffect(() => { + if (!isActive && thinkingAutoCollapse) { + setExpanded(false) + } + }, [isActive, thinkingAutoCollapse]) + + // Force expand while actively thinking so user can see the stream + useEffect(() => { + if (isActive) { + setExpanded(true) + } + }, [isActive]) + useEffect(() => { if (expanded && isActive && contentRef.current) { contentRef.current.scrollTop = contentRef.current.scrollHeight @@ -26,6 +69,7 @@ export function ThinkingBlock({ content, isActive = false }: { content: string; {expanded ? '\u25BE' : '\u25B8'} + {isActive ? t('thinking.label') : t('thinking.labelDone')} {isActive && } diff --git a/desktop/src/components/chat/chatBlocks.test.tsx b/desktop/src/components/chat/chatBlocks.test.tsx index 53d74c3f..68b32ec6 100644 --- a/desktop/src/components/chat/chatBlocks.test.tsx +++ b/desktop/src/components/chat/chatBlocks.test.tsx @@ -25,6 +25,9 @@ describe('chat blocks', () => { it('does not animate inactive historical thinking blocks', () => { const { container } = render() + // Auto-collapsed by default; click to expand + fireEvent.click(screen.getByRole('button', { name: /Thought/ })) + expect(container.textContent).toContain('old reasoning') expect(container.querySelector('.thinking-cursor')).toBeNull() @@ -34,9 +37,12 @@ describe('chat blocks', () => { expect(container.querySelector('.thinking-cursor')).toBeNull() }) - it('renders thinking content as markdown by default and hides it after collapsing', () => { + it('renders thinking content as markdown after expanding and hides it after collapsing', () => { const { container } = render() + // Auto-collapsed by default; click to expand + fireEvent.click(screen.getByRole('button', { name: /Thought/ })) + expect(container.querySelector('strong')?.textContent).toBe('important') expect(container.querySelector('li')?.textContent).toBe('item one') @@ -47,10 +53,13 @@ describe('chat blocks', () => { expect(container.querySelector('li')).toBeNull() }) - it('shows full thinking content by default and hides it after collapsing', () => { + it('shows full thinking content after expanding and hides it after collapsing', () => { const content = Array.from({ length: 12 }, (_, index) => `line-${index + 1}`).join('\n') const { container } = render() + // Auto-collapsed by default; click to expand + fireEvent.click(screen.getByRole('button', { name: /Thought/ })) + expect(container.textContent).toContain('line-1') expect(container.textContent).toContain('line-11') expect(container.textContent).toContain('line-12') diff --git a/desktop/src/hooks/useKeyboardShortcuts.ts b/desktop/src/hooks/useKeyboardShortcuts.ts index 282dc99e..4342e653 100644 --- a/desktop/src/hooks/useKeyboardShortcuts.ts +++ b/desktop/src/hooks/useKeyboardShortcuts.ts @@ -13,6 +13,7 @@ export function useKeyboardShortcuts() { const setActiveSession = useSessionStore((s) => s.setActiveSession) const setActiveView = useUIStore((s) => s.setActiveView) const setSidebarOpen = useUIStore((s) => s.setSidebarOpen) + const toggleSidebar = useUIStore((s) => s.toggleSidebar) const closeModal = useUIStore((s) => s.closeModal) const activeModal = useUIStore((s) => s.activeModal) const stopGeneration = useChatStore((s) => s.stopGeneration) @@ -50,6 +51,13 @@ export function useKeyboardShortcuts() { setActiveView('code') } + // Cmd+B / Ctrl+B — Toggle sidebar + if (meta && e.key === 'b') { + e.preventDefault() + toggleSidebar() + return + } + // Cmd+K — Focus search (sidebar search input) if (meta && e.key === 'k') { e.preventDefault() @@ -79,5 +87,5 @@ export function useKeyboardShortcuts() { document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) - }, [closeModal, setActiveSession, setActiveView, setSidebarOpen, setUiZoom, stopGeneration]) + }, [closeModal, setActiveSession, setActiveView, setSidebarOpen, toggleSidebar, setUiZoom, stopGeneration]) } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 9dba1046..a2618079 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1099,6 +1099,7 @@ export const en = { 'settings.general.thinkingDescription': 'Controls whether new sessions start with model thinking enabled. When off, compatible providers such as DeepSeek receive an explicit non-thinking parameter.', 'settings.general.thinkingEnabled': 'Enable thinking mode', 'settings.general.thinkingHint': 'Turn this off to start new sessions with --thinking disabled; useful for DeepSeek V4 Flash/Pro and other non-thinking workflows.', + 'settings.general.thinkingAutoCollapse': 'Auto-collapse thinking blocks', 'settings.general.notificationsTitle': 'System Notifications', 'settings.general.notificationsDescription': 'Use native OS notifications for permission prompts, agent replies, and scheduled task results.', 'settings.general.notificationsEnabled': 'Enable system notifications', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 842c3de1..a4be772b 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1101,6 +1101,7 @@ export const jp: Record = { 'settings.general.thinkingDescription': '新しいセッションをモデルの思考を有効にして開始するかどうかを制御します。オフの場合、DeepSeek などの対応プロバイダーには明示的に非思考パラメーターが渡されます。', 'settings.general.thinkingEnabled': '思考モードを有効にする', 'settings.general.thinkingHint': '新しいセッションを --thinking 無効で開始するにはこれをオフにします。DeepSeek V4 Flash/Pro やその他の非思考ワークフローに便利です。', + 'settings.general.thinkingAutoCollapse': '思考ブロックを自動折りたたみ', 'settings.general.notificationsTitle': 'システム通知', 'settings.general.notificationsDescription': '権限の確認、エージェントの応答、スケジュールタスクの結果に OS ネイティブの通知を使用します。', 'settings.general.notificationsEnabled': 'システム通知を有効にする', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 3be460d9..e3b8c56d 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1101,6 +1101,7 @@ export const kr: Record = { 'settings.general.thinkingDescription': '새 세션을 모델 사고를 사용으로 시작할지 제어합니다. 꺼져 있으면 DeepSeek 같은 호환 공급자에 명시적인 비사고 매개변수가 전달됩니다.', 'settings.general.thinkingEnabled': '사고 모드 사용', 'settings.general.thinkingHint': '새 세션을 --thinking 비활성화로 시작하려면 이를 끄세요. DeepSeek V4 Flash/Pro 및 기타 비사고 워크플로에 유용합니다.', + 'settings.general.thinkingAutoCollapse': '사고 블록 자동 접기', 'settings.general.notificationsTitle': '시스템 알림', 'settings.general.notificationsDescription': '권한 확인, 에이전트 응답, 예약 작업 결과에 OS 기본 알림을 사용합니다.', 'settings.general.notificationsEnabled': '시스템 알림 사용', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 5505575f..2c15293f 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1101,6 +1101,7 @@ export const zh: Record = { 'settings.general.thinkingDescription': '控制新會話是否啟用模型思考。關閉後,DeepSeek 等相容供應商會收到顯式非思考模式引數。', 'settings.general.thinkingEnabled': '啟用思考模式', 'settings.general.thinkingHint': '關閉後會以 --thinking disabled 啟動新會話;適合 DeepSeek V4 Flash/Pro 等需要非思考模式的模型。', + 'settings.general.thinkingAutoCollapse': '自動摺疊思考內容', 'settings.general.notificationsTitle': '系統通知', 'settings.general.notificationsDescription': '使用作業系統原生通知提醒授權確認、Agent 回覆完成和定時任務結果。', 'settings.general.notificationsEnabled': '啟用系統通知', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index a449c2fa..55117d0d 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1101,6 +1101,7 @@ export const zh: Record = { 'settings.general.thinkingDescription': '控制新会话是否启用模型思考。关闭后,DeepSeek 等兼容供应商会收到显式非思考模式参数。', 'settings.general.thinkingEnabled': '启用思考模式', 'settings.general.thinkingHint': '关闭后会以 --thinking disabled 启动新会话;适合 DeepSeek V4 Flash/Pro 等需要非思考模式的模型。', + 'settings.general.thinkingAutoCollapse': '自动折叠思考内容', 'settings.general.notificationsTitle': '系统通知', 'settings.general.notificationsDescription': '使用操作系统原生通知提醒授权确认、Agent 回复完成和定时任务结果。', 'settings.general.notificationsEnabled': '启用系统通知', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index a1904bf7..ec15292d 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -1811,6 +1811,8 @@ export function GeneralSettings() { const { thinkingEnabled, setThinkingEnabled, + thinkingAutoCollapse, + setThinkingAutoCollapse, locale, setLocale, theme, @@ -2466,6 +2468,21 @@ export function GeneralSettings() { +
diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index 30b97603..670c710a 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -60,6 +60,7 @@ type SettingsStore = { currentModel: ModelInfo | null effortLevel: EffortLevel thinkingEnabled: boolean + thinkingAutoCollapse: boolean availableModels: ModelInfo[] activeProviderName: string | null locale: Locale @@ -95,6 +96,7 @@ type SettingsStore = { setModel: (modelId: string) => Promise setEffort: (level: EffortLevel) => Promise setThinkingEnabled: (enabled: boolean) => Promise + setThinkingAutoCollapse: (enabled: boolean) => Promise setLocale: (locale: Locale) => void setTheme: (theme: ThemeMode) => Promise setChatSendBehavior: (behavior: ChatSendBehavior) => Promise @@ -169,6 +171,7 @@ export const useSettingsStore = create((set, get) => ({ currentModel: null, effortLevel: 'max', thinkingEnabled: true, + thinkingAutoCollapse: true, availableModels: [], activeProviderName: null, locale: getStoredLocale(), @@ -237,6 +240,7 @@ export const useSettingsStore = create((set, get) => ({ currentModel: model, effortLevel: level, thinkingEnabled: userSettings.alwaysThinkingEnabled !== false, + thinkingAutoCollapse: userSettings.thinkingAutoCollapse !== false, theme, chatSendBehavior: normalizeChatSendBehavior(userSettings.chatSendBehavior), outputStyle: normalizeOutputStyle(userSettings.outputStyle), @@ -307,6 +311,16 @@ export const useSettingsStore = create((set, get) => ({ } }, + setThinkingAutoCollapse: async (enabled) => { + const prev = get().thinkingAutoCollapse + set({ thinkingAutoCollapse: enabled }) + try { + await settingsApi.updateUser({ thinkingAutoCollapse: enabled }) + } catch { + set({ thinkingAutoCollapse: prev }) + } + }, + setLocale: (locale) => { set({ locale }) try { localStorage.setItem(LOCALE_STORAGE_KEY, locale) } catch { /* noop */ } diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css index 0b895c9a..45dff515 100644 --- a/desktop/src/theme/globals.css +++ b/desktop/src/theme/globals.css @@ -1256,6 +1256,15 @@ button, input, textarea, select, a, [role="button"] { animation: fade-in 200ms ease-out; } +/* Thinking brain icon pulse animation */ +@keyframes thinking-brain-pulse { + 0%, 100% { transform: scale(1); opacity: 0.85; } + 50% { transform: scale(1.08); opacity: 1; } +} +.thinking-brain-icon { + animation: thinking-brain-pulse 1.8s ease-in-out infinite; +} + /* Thinking block animations */ @keyframes thinking-cursor-blink { 0%, 100% { opacity: 1; }