feat(desktop): add Ctrl+B sidebar toggle and thinking block auto-collapse

- Add Ctrl+B / Cmd+B keyboard shortcut to toggle sidebar visibility
- Add thinkingAutoCollapse setting (default: true) to auto-collapse
  thinking blocks when they finish; active thinking streams stay expanded
- Add animated brain SVG icon for active thinking state with pulse
  animation; static psychology icon for completed thinking
- Add settings toggle for auto-collapse under Settings > General >
  Thinking section
- Add i18n translations for all 5 locales (en/zh/zh-TW/jp/kr)
- Update ThinkingBlock tests to account for default-collapsed behavior

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
你的姓名 2026-06-14 00:19:43 +08:00
parent 18b7e8e2c8
commit 37202ff768
11 changed files with 110 additions and 4 deletions

View File

@ -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 (
<svg
className="thinking-brain-icon h-[14px] w-[14px] shrink-0 text-[var(--color-primary)]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 2a6 6 0 0 0-6 6c0 1.6.6 3 1.7 4.1L12 17l4.3-4.9A6 6 0 0 0 18 8a6 6 0 0 0-6-6z" />
<path d="M9 8a3 3 0 0 1 6 0" />
<path d="M12 17v5" />
<path d="M8 22h8" />
</svg>
)
}
return (
<span className="material-symbols-outlined shrink-0 text-[14px] text-[var(--color-outline)]" aria-hidden="true">
psychology
</span>
)
}
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<HTMLDivElement>(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;
<span className="text-[10px] text-[var(--color-outline)]">
{expanded ? '\u25BE' : '\u25B8'}
</span>
<ThinkingBrainIcon isActive={isActive} />
<span className="shrink-0 font-medium italic">
{isActive ? t('thinking.label') : t('thinking.labelDone')}
{isActive && <span className="thinking-dots" />}

View File

@ -25,6 +25,9 @@ describe('chat blocks', () => {
it('does not animate inactive historical thinking blocks', () => {
const { container } = render(<ThinkingBlock content="old reasoning" isActive={false} />)
// 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(<ThinkingBlock content={'**important**\n\n- item one'} />)
// 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(<ThinkingBlock content={content} />)
// 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')

View File

@ -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])
}

View File

@ -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',

View File

@ -1101,6 +1101,7 @@ export const jp: Record<TranslationKey, string> = {
'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': 'システム通知を有効にする',

View File

@ -1101,6 +1101,7 @@ export const kr: Record<TranslationKey, string> = {
'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': '시스템 알림 사용',

View File

@ -1101,6 +1101,7 @@ export const zh: Record<TranslationKey, string> = {
'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': '啟用系統通知',

View File

@ -1101,6 +1101,7 @@ export const zh: Record<TranslationKey, string> = {
'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': '启用系统通知',

View File

@ -1811,6 +1811,8 @@ export function GeneralSettings() {
const {
thinkingEnabled,
setThinkingEnabled,
thinkingAutoCollapse,
setThinkingAutoCollapse,
locale,
setLocale,
theme,
@ -2466,6 +2468,21 @@ export function GeneralSettings() {
</div>
</div>
</label>
<label className="mt-2 flex items-start gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 cursor-pointer hover:border-[var(--color-border-focus)] transition-colors">
<input
type="checkbox"
aria-label={t('settings.general.thinkingAutoCollapse')}
checked={thinkingAutoCollapse}
onChange={(e) => void setThinkingAutoCollapse(e.target.checked)}
className="peer sr-only"
/>
<SettingsCheckboxMark checked={thinkingAutoCollapse} />
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.thinkingAutoCollapse')}
</div>
</div>
</label>
</div>
<div className="mt-8">

View File

@ -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<void>
setEffort: (level: EffortLevel) => Promise<void>
setThinkingEnabled: (enabled: boolean) => Promise<void>
setThinkingAutoCollapse: (enabled: boolean) => Promise<void>
setLocale: (locale: Locale) => void
setTheme: (theme: ThemeMode) => Promise<void>
setChatSendBehavior: (behavior: ChatSendBehavior) => Promise<void>
@ -169,6 +171,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
currentModel: null,
effortLevel: 'max',
thinkingEnabled: true,
thinkingAutoCollapse: true,
availableModels: [],
activeProviderName: null,
locale: getStoredLocale(),
@ -237,6 +240,7 @@ export const useSettingsStore = create<SettingsStore>((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<SettingsStore>((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 */ }

View File

@ -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; }