mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-26 15:03:34 +08:00
Merge pull request #46 from 706412584/worktree-solo-council-collapse-approval
feat(desktop): add Ctrl+B sidebar toggle and thinking block auto-collapse
This commit is contained in:
commit
395599974c
@ -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" />}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': 'システム通知を有効にする',
|
||||
|
||||
@ -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': '시스템 알림 사용',
|
||||
|
||||
@ -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': '啟用系統通知',
|
||||
|
||||
@ -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': '启用系统通知',
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 */ }
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user