diff --git a/packages/ui/src/components/chat/message/parts/UserTextPart.tsx b/packages/ui/src/components/chat/message/parts/UserTextPart.tsx index b7955325..2cfbb1a0 100644 --- a/packages/ui/src/components/chat/message/parts/UserTextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/UserTextPart.tsx @@ -8,6 +8,7 @@ import { useSkillsStore } from '@/stores/useSkillsStore'; import { Icon } from "@/components/icon/Icon"; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { getDirectoryForFilePath } from '@/lib/path-utils'; +import { useI18n } from '@/lib/i18n'; import { buildAgentHref, buildAgentMentionUrl, @@ -57,9 +58,11 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti const [isExpanded, setIsExpanded] = React.useState(false); const [isTruncated, setIsTruncated] = React.useState(false); const userMessageRenderingMode = useUIStore((state) => state.userMessageRenderingMode); + const collapsibleUserMessages = useUIStore((state) => state.collapsibleUserMessages); const skills = useSkillsStore((state) => state.skills); const openContextFile = useUIStore((state) => state.openContextFile); const effectiveDirectory = useEffectiveDirectory(); + const { t } = useI18n(); const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode); const textRef = React.useRef(null); const skillByName = React.useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]); @@ -89,7 +92,7 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti if (!el) return; const checkTruncation = () => { - if (!isExpanded) { + if (collapsibleUserMessages && !isExpanded) { setIsTruncated(el.scrollHeight > el.clientHeight); } }; @@ -100,7 +103,14 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti resizeObserver.observe(el); return () => resizeObserver.disconnect(); - }, [textContent, isExpanded]); + }, [collapsibleUserMessages, textContent, isExpanded]); + + React.useEffect(() => { + if (!collapsibleUserMessages) { + setIsExpanded(false); + setIsTruncated(false); + } + }, [collapsibleUserMessages]); const handleClick = React.useCallback((event: React.MouseEvent) => { const target = event.target as HTMLElement | null; @@ -123,10 +133,10 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti return; } - if (!isExpanded && isTruncated) { + if (collapsibleUserMessages && !isExpanded && isTruncated) { setIsExpanded(true); } - }, [hasActiveSelectionInElement, isExpanded, isTruncated, openSkill]); + }, [collapsibleUserMessages, hasActiveSelectionInElement, isExpanded, isTruncated, openSkill]); const handleCollapse = React.useCallback((event: React.MouseEvent) => { event.stopPropagation(); @@ -222,12 +232,12 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti return (
- {isExpanded && ( + {collapsibleUserMessages && isExpanded && ( @@ -237,8 +247,8 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti "break-words font-sans typography-markdown-body", isExpanded && "pb-3", normalizedRenderingMode === 'plain' && 'whitespace-pre-wrap', - !isExpanded && "line-clamp-2", - isTruncated && !isExpanded && "cursor-pointer" + collapsibleUserMessages && !isExpanded && "line-clamp-2", + collapsibleUserMessages && isTruncated && !isExpanded && "cursor-pointer" )} ref={textRef} onClick={handleClick} diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 81424e33..d9ee3611 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -143,7 +143,7 @@ const VisualSectionContent: React.FC = () => { // Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft const ChatSectionContent: React.FC = () => { - return ; + return ; }; // Sessions section: Default model & agent, Session retention diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index ab20c6a5..8a558db9 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -247,7 +247,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' return mode === 'markdown' ? 'markdown' : 'plain'; }; -export type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage'; +export type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage'; interface OpenChamberVisualSettingsProps { /** Which settings to show. If undefined, shows all. */ @@ -269,6 +269,8 @@ export const OpenChamberVisualSettings: React.FC const setMermaidRenderingMode = useUIStore(state => state.setMermaidRenderingMode); const userMessageRenderingMode = useUIStore(state => state.userMessageRenderingMode); const setUserMessageRenderingMode = useUIStore(state => state.setUserMessageRenderingMode); + const collapsibleUserMessages = useUIStore(state => state.collapsibleUserMessages); + const setCollapsibleUserMessages = useUIStore(state => state.setCollapsibleUserMessages); const stickyUserHeader = useUIStore(state => state.stickyUserHeader); const setStickyUserHeader = useUIStore(state => state.setStickyUserHeader); const wideChatLayoutEnabled = useUIStore(state => state.wideChatLayoutEnabled); @@ -413,6 +415,11 @@ export const OpenChamberVisualSettings: React.FC void updateDesktopSettings({ stickyUserHeader: enabled }); }, [setStickyUserHeader]); + const handleCollapsibleUserMessagesChange = React.useCallback((enabled: boolean) => { + setCollapsibleUserMessages(enabled); + void updateDesktopSettings({ collapsibleUserMessages: enabled }); + }, [setCollapsibleUserMessages]); + const handleWideChatLayoutChange = React.useCallback((enabled: boolean) => { setWideChatLayoutEnabled(enabled); void updateDesktopSettings({ wideChatLayoutEnabled: enabled }); @@ -532,6 +539,7 @@ export const OpenChamberVisualSettings: React.FC || shouldShow('chatRenderMode') || shouldShow('messageTransport') || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted') + || shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') @@ -1696,7 +1704,7 @@ export const OpenChamberVisualSettings: React.FC
)} - {(shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && ( + {(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
{shouldShow('reasoning') && (
)} + {shouldShow('collapsibleUserMessages') && ( +
handleCollapsibleUserMessagesChange(!collapsibleUserMessages)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + handleCollapsibleUserMessagesChange(!collapsibleUserMessages); + } + }} + > + + {t('settings.openchamber.visual.field.collapsibleUserMessages')} +
+ )} + {shouldShow('stickyUserHeader') && (
{ store.setUserMessageRenderingMode(settings.userMessageRenderingMode); } } + if (typeof settings.collapsibleUserMessages === 'boolean' && settings.collapsibleUserMessages !== store.collapsibleUserMessages) { + store.setCollapsibleUserMessages(settings.collapsibleUserMessages); + } if (typeof settings.messageStreamTransport === 'string' && (settings.messageStreamTransport === 'auto' || settings.messageStreamTransport === 'ws' || settings.messageStreamTransport === 'sse')) { if (configStore && settings.messageStreamTransport !== configStore.settingsMessageStreamTransport) { @@ -997,6 +1000,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { && (candidate.userMessageRenderingMode === 'markdown' || candidate.userMessageRenderingMode === 'plain')) { result.userMessageRenderingMode = candidate.userMessageRenderingMode; } + if (typeof candidate.collapsibleUserMessages === 'boolean') { + result.collapsibleUserMessages = candidate.collapsibleUserMessages; + } if (typeof candidate.stickyUserHeader === 'boolean') { result.stickyUserHeader = candidate.stickyUserHeader; } diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 73e77f3c..6ad1812e 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -160,6 +160,12 @@ export const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ titleKey: 'settings.openchamber.visual.field.stickyUserHeader', keywords: ['messages', 'header'], }, + { + id: 'chat.collapsible-user-messages', + page: 'chat', + titleKey: 'settings.openchamber.visual.field.collapsibleUserMessages', + keywords: ['collapse', 'expand', 'clamp', 'long messages', 'user messages'], + }, { id: 'chat.wide-layout', page: 'chat', diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index bbb9c2c5..28201da1 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -618,6 +618,7 @@ interface UIStore { weekStartPreference: WeekStartPreference; mermaidRenderingMode: MermaidRenderingMode; userMessageRenderingMode: UserMessageRenderingMode; + collapsibleUserMessages: boolean; stickyUserHeader: boolean; showSplitAssistantMessageActions: boolean; isMobileSessionStatusBarCollapsed: boolean; @@ -760,6 +761,7 @@ interface UIStore { setWeekStartPreference: (value: WeekStartPreference) => void; setMermaidRenderingMode: (value: MermaidRenderingMode) => void; setUserMessageRenderingMode: (value: UserMessageRenderingMode) => void; + setCollapsibleUserMessages: (value: boolean) => void; setStickyUserHeader: (value: boolean) => void; setShowSplitAssistantMessageActions: (value: boolean) => void; setIsMobileSessionStatusBarCollapsed: (value: boolean) => void; @@ -895,6 +897,7 @@ export const useUIStore = create()( weekStartPreference: 'auto', mermaidRenderingMode: 'svg', userMessageRenderingMode: 'markdown', + collapsibleUserMessages: true, stickyUserHeader: false, showSplitAssistantMessageActions: false, isMobileSessionStatusBarCollapsed: false, @@ -2001,6 +2004,9 @@ export const useUIStore = create()( setUserMessageRenderingMode: (value) => { set({ userMessageRenderingMode: value }); }, + setCollapsibleUserMessages: (value) => { + set({ collapsibleUserMessages: value }); + }, setStickyUserHeader: (value) => { set({ stickyUserHeader: value }); }, @@ -2232,6 +2238,7 @@ export const useUIStore = create()( weekStartPreference: state.weekStartPreference, mermaidRenderingMode: state.mermaidRenderingMode, userMessageRenderingMode: state.userMessageRenderingMode, + collapsibleUserMessages: state.collapsibleUserMessages, stickyUserHeader: state.stickyUserHeader, showSplitAssistantMessageActions: state.showSplitAssistantMessageActions, isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed,