diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 8a9e4ab0..a2ecf62d 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -735,11 +735,14 @@ const ChatMessage: React.FC = ({ const handleCopyMessage = React.useCallback(async () => { const result = await copyTextToClipboard(messageTextContent); if (!result.ok) { - return; + return false; } - setCopiedMessage(true); - setTimeout(() => setCopiedMessage(false), 2000); - }, [messageTextContent]); + if (isUser) { + setCopiedMessage(true); + setTimeout(() => setCopiedMessage(false), 2000); + } + return true; + }, [isUser, messageTextContent]); const handleRevert = React.useCallback(() => { if (!sessionId || !message.info.id) return; diff --git a/packages/ui/src/components/chat/components/TurnActivity.tsx b/packages/ui/src/components/chat/components/TurnActivity.tsx index 8cc29554..2523ff03 100644 --- a/packages/ui/src/components/chat/components/TurnActivity.tsx +++ b/packages/ui/src/components/chat/components/TurnActivity.tsx @@ -28,6 +28,7 @@ interface TurnActivityProps { animateRows?: boolean; animatedToolIds?: Set; diffStats?: DiffStats; + renderJustificationActions?: (activity: TurnActivityRecord) => React.ReactNode; } const TurnActivity: React.FC = (props) => { diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 23d37be2..f838d483 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -46,6 +46,7 @@ import { useI18n } from '@/lib/i18n'; const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' }; const MESSAGE_FOOTER_CONTAINER_STYLE = { containerType: 'inline-size' as const, containerName: 'message-footer' }; +const INLINE_MESSAGE_ACTIONS_CLASS_NAME = 'mt-2 mb-1 flex items-center justify-start gap-1.5'; type SubtaskPartLike = Part & { type: 'subtask'; @@ -292,7 +293,7 @@ interface MessageBodyProps { shouldShowHeader?: boolean; hasTextContent?: boolean; - onCopyMessage?: () => void; + onCopyMessage?: () => void | boolean | Promise; copiedMessage?: boolean; onAuxiliaryContentComplete?: () => void; showReasoningTraces?: boolean; @@ -577,6 +578,253 @@ const UserMessageBody = React.memo(({ messageId, parts, isMobile, hasTouchInput, ); }); +interface AssistantMessageActionButtonsProps { + hasCopyableText: boolean; + isTouchContext: boolean; + onCopyMessage?: () => void | boolean | Promise; + onShareImage: (sourceElement?: HTMLElement | null) => Promise; + ttsText: string; +} + +const AssistantMessageActionButtons = React.memo(({ + hasCopyableText, + isTouchContext, + onCopyMessage, + onShareImage, + ttsText, +}: AssistantMessageActionButtonsProps) => { + const { t } = useI18n(); + const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS(); + const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); + const voiceProvider = useConfigStore((state) => state.voiceProvider); + const [copyHintVisible, setCopyHintVisible] = React.useState(false); + const [isMessageCopied, setIsMessageCopied] = React.useState(false); + const [isSharing, setIsSharing] = React.useState(false); + const copyHintTimeoutRef = React.useRef(null); + const copiedResetTimeoutRef = React.useRef(null); + const canCopyMessage = Boolean(onCopyMessage); + + const clearCopyHintTimeout = React.useCallback(() => { + if (copyHintTimeoutRef.current !== null && typeof window !== 'undefined') { + window.clearTimeout(copyHintTimeoutRef.current); + copyHintTimeoutRef.current = null; + } + }, []); + + const clearCopiedResetTimeout = React.useCallback(() => { + if (copiedResetTimeoutRef.current !== null && typeof window !== 'undefined') { + window.clearTimeout(copiedResetTimeoutRef.current); + copiedResetTimeoutRef.current = null; + } + }, []); + + React.useEffect(() => { + return () => { + clearCopyHintTimeout(); + clearCopiedResetTimeout(); + }; + }, [clearCopiedResetTimeout, clearCopyHintTimeout]); + + React.useEffect(() => { + if (!hasCopyableText || !canCopyMessage) { + setCopyHintVisible(false); + setIsMessageCopied(false); + clearCopyHintTimeout(); + clearCopiedResetTimeout(); + } + }, [canCopyMessage, clearCopiedResetTimeout, clearCopyHintTimeout, hasCopyableText]); + + const revealCopyHint = React.useCallback(() => { + if (!isTouchContext || !canCopyMessage || !hasCopyableText || typeof window === 'undefined') { + return; + } + + clearCopyHintTimeout(); + setCopyHintVisible(true); + copyHintTimeoutRef.current = window.setTimeout(() => { + setCopyHintVisible(false); + copyHintTimeoutRef.current = null; + }, 1800); + }, [canCopyMessage, clearCopyHintTimeout, hasCopyableText, isTouchContext]); + + const handleCopyButtonClick = React.useCallback( + async (event: React.MouseEvent) => { + if (!onCopyMessage || !hasCopyableText) { + return; + } + + event.stopPropagation(); + event.preventDefault(); + + const copied = await onCopyMessage(); + if (copied === false) { + return; + } + + clearCopiedResetTimeout(); + setIsMessageCopied(true); + if (typeof window !== 'undefined') { + copiedResetTimeoutRef.current = window.setTimeout(() => { + setIsMessageCopied(false); + copiedResetTimeoutRef.current = null; + }, 2000); + } + + if (isTouchContext) { + revealCopyHint(); + } + }, + [clearCopiedResetTimeout, hasCopyableText, isTouchContext, onCopyMessage, revealCopyHint] + ); + + const handleShareImageClick = React.useCallback( + async (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + + if (isSharing || !hasCopyableText) { + return; + } + + setIsSharing(true); + try { + const root = event.currentTarget.closest('[data-message-text-export-root]'); + const sourceElement = root?.querySelector('[data-message-text-export-source]') ?? null; + await onShareImage(sourceElement); + } finally { + setIsSharing(false); + } + }, + [hasCopyableText, isSharing, onShareImage] + ); + + const readAloudTooltip = React.useMemo(() => { + if (isTTSPlaying) { + return t('chat.messageBody.tts.stopSpeaking'); + } + const providerLabel = voiceProvider === 'browser' + ? 'Browser' + : voiceProvider === 'openai' + ? 'OpenAI' + : voiceProvider === 'openai-compatible' + ? 'Custom' + : 'Say'; + return t('chat.messageBody.tts.readAloudWithProvider', { provider: providerLabel }); + }, [isTTSPlaying, t, voiceProvider]); + + const handleTTSClick = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + + if (isTTSPlaying) { + stopTTS(); + return; + } + + if (ttsText.trim()) { + void playTTS(ttsText); + } + }, + [isTTSPlaying, playTTS, stopTTS, ttsText] + ); + + return ( + <> + {onCopyMessage && ( + + + + + {t('chat.messageBody.actions.copyAnswer')} + + )} + + + + + {isSharing ? t('chat.messageBody.actions.savingImage') : t('chat.messageBody.actions.saveAsImage')} + + {showMessageTTSButtons && hasCopyableText && ( + + + + + {readAloudTooltip} + + )} + + ); +}); + const AssistantMessageBody = React.memo(({ sessionId, messageId, @@ -597,7 +845,6 @@ const AssistantMessageBody = React.memo(({ onContentChange, hasTextContent = false, onCopyMessage, - copiedMessage = false, onAuxiliaryContentComplete, showReasoningTraces = false, turnGroupingContext, @@ -606,17 +853,14 @@ const AssistantMessageBody = React.memo(({ const { t } = useI18n(); const streamPhase = _streamPhase; void _allowAnimation; - const [copyHintVisible, setCopyHintVisible] = React.useState(false); - const copyHintTimeoutRef = React.useRef(null); const messageContentRef = React.useRef(null); + const messageTextContentRef = React.useRef(null); const toolRevealReadyRef = React.useRef(false); React.useEffect(() => { toolRevealReadyRef.current = true; }, []); - const canCopyMessage = Boolean(onCopyMessage); - const isMessageCopied = Boolean(copiedMessage); const isTouchContext = Boolean(hasTouchInput ?? isMobile); const awaitingMessageCompletion = !isMessageCompleted; const animateActivityRows = awaitingMessageCompletion || Boolean(turnGroupingContext?.isWorking); @@ -725,24 +969,12 @@ const AssistantMessageBody = React.memo(({ const [isPlanDialogOpen, setIsPlanDialogOpen] = React.useState(false); const [isSavingPlan, setIsSavingPlan] = React.useState(false); const chatRenderMode = useUIStore((state) => state.chatRenderMode); + const showSplitAssistantMessageActions = useUIStore((state) => state.showSplitAssistantMessageActions); const isSortedRenderMode = chatRenderMode === 'sorted'; const collapsedPreviewCount = 7; const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false; const hasStopFinish = messageFinish === 'stop'; - // TTS for message playback - const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS(); - const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); - const voiceProvider = useConfigStore((state) => state.voiceProvider); - - const readAloudTooltip = React.useMemo(() => { - if (isTTSPlaying) { - return t('chat.messageBody.tts.stopSpeaking'); - } - const providerLabel = voiceProvider === 'browser' ? 'Browser' : voiceProvider === 'openai' ? 'OpenAI' : voiceProvider === 'openai-compatible' ? 'Custom' : 'Say'; - return t('chat.messageBody.tts.readAloudWithProvider', { provider: providerLabel }); - }, [isTTSPlaying, t, voiceProvider]); - const currentSession = React.useMemo(() => { if (!currentSessionId) { return null; @@ -881,50 +1113,6 @@ const AssistantMessageBody = React.memo(({ const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion; - const clearCopyHintTimeout = React.useCallback(() => { - if (copyHintTimeoutRef.current !== null && typeof window !== 'undefined') { - window.clearTimeout(copyHintTimeoutRef.current); - copyHintTimeoutRef.current = null; - } - }, []); - - const revealCopyHint = React.useCallback(() => { - if (!isTouchContext || !canCopyMessage || !hasCopyableText || typeof window === 'undefined') { - return; - } - - clearCopyHintTimeout(); - setCopyHintVisible(true); - copyHintTimeoutRef.current = window.setTimeout(() => { - setCopyHintVisible(false); - copyHintTimeoutRef.current = null; - }, 1800); - }, [canCopyMessage, clearCopyHintTimeout, hasCopyableText, isTouchContext]); - - React.useEffect(() => { - if (!hasCopyableText) { - setCopyHintVisible(false); - clearCopyHintTimeout(); - } - }, [clearCopyHintTimeout, hasCopyableText]); - - const handleCopyButtonClick = React.useCallback( - (event: React.MouseEvent) => { - if (!onCopyMessage || !hasCopyableText) { - return; - } - - event.stopPropagation(); - event.preventDefault(); - onCopyMessage(); - - if (isTouchContext) { - revealCopyHint(); - } - }, - [hasCopyableText, isTouchContext, onCopyMessage, revealCopyHint] - ); - const handleForkClick = React.useCallback( (event: React.MouseEvent) => { event.stopPropagation(); @@ -952,23 +1140,6 @@ const AssistantMessageBody = React.memo(({ [assistantPlanText, openMultiRunLauncherWithPrompt] ); - const handleTTSClick = React.useCallback( - (event: React.MouseEvent) => { - event.stopPropagation(); - event.preventDefault(); - - if (isTTSPlaying) { - stopTTS(); - return; - } - - if (assistantPlanText.trim()) { - void playTTS(assistantPlanText); - } - }, - [assistantPlanText, isTTSPlaying, playTTS, stopTTS] - ); - const handleSaveAsPlanClick = React.useCallback( (event: React.MouseEvent) => { event.stopPropagation(); @@ -1013,19 +1184,14 @@ const AssistantMessageBody = React.memo(({ [assistantPlanText, currentProjectRef, t] ); - const [isSharing, setIsSharing] = React.useState(false); + const shareMessageAsImage = React.useCallback( + async (requestedSourceElement?: HTMLElement | null) => { + const sourceElement = requestedSourceElement ?? messageTextContentRef.current ?? messageContentRef.current; + if (!sourceElement) return; - const handleShareImage = React.useCallback( - async (event: React.MouseEvent) => { - event.stopPropagation(); - event.preventDefault(); - - if (!messageContentRef.current || isSharing) return; - - setIsSharing(true); let wrapper: HTMLDivElement | null = null; try { - const originalElement = messageContentRef.current; + const originalElement = sourceElement; const computedStyle = window.getComputedStyle(originalElement); const rootStyle = window.getComputedStyle(document.documentElement); const resolvedBackgroundColor = @@ -1048,6 +1214,15 @@ const AssistantMessageBody = React.memo(({ contain: none; `; + const actionRows = clone.querySelectorAll('[data-message-actions="true"]'); + actionRows.forEach((row) => { + row.style.display = 'none'; + }); + const actionGroups = clone.querySelectorAll('[data-message-action-group="true"]'); + actionGroups.forEach((group) => { + group.style.display = 'none'; + }); + const timestampElements = clone.querySelectorAll('[aria-label^="Message time:"]'); const footerRowsAdjusted = new Set(); timestampElements.forEach((element) => { @@ -1064,12 +1239,10 @@ const AssistantMessageBody = React.memo(({ const metaGroup = element.parentElement; const footerRow = metaGroup?.parentElement as HTMLElement | null; - const actionsGroup = footerRow?.firstElementChild as HTMLElement | null; - if (!footerRow || !actionsGroup || actionsGroup === metaGroup || footerRowsAdjusted.has(footerRow)) { + if (!footerRow || footerRowsAdjusted.has(footerRow)) { return; } - actionsGroup.style.display = 'none'; footerRow.style.justifyContent = 'flex-start'; footerRowsAdjusted.add(footerRow); }); @@ -1120,18 +1293,11 @@ const AssistantMessageBody = React.memo(({ if (wrapper && wrapper.parentNode) { wrapper.parentNode.removeChild(wrapper); } - setIsSharing(false); } }, - [messageId, isSharing, t] + [messageId, t] ); - React.useEffect(() => { - return () => { - clearCopyHintTimeout(); - }; - }, [clearCopyHintTimeout]); - const activityPartsForTurn = React.useMemo(() => { const all = turnGroupingContext?.activityParts; if (!isSortedRenderMode || !all) { @@ -1188,6 +1354,73 @@ const AssistantMessageBody = React.memo(({ && Boolean(toggleActivityGroup); const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish; + const showErrorMessage = Boolean(errorMessage); + const shouldShowMessageActions = hasCopyableText; + const shouldShowTurnFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage)); + const shouldRenderActionsInActivity = isSortedRenderMode; + const shouldShowStandaloneMessageActions = showSplitAssistantMessageActions && shouldShowMessageActions && !shouldShowTurnFooter && !shouldRenderActionsInActivity; + + const messageActionButtons = React.useMemo(() => ( + + ), [assistantPlanText, hasCopyableText, isTouchContext, onCopyMessage, shareMessageAsImage]); + + const renderJustificationActions = React.useCallback((activity: NonNullable[number]) => { + if (!showSplitAssistantMessageActions || !isSortedRenderMode) { + return null; + } + + const text = extractTextContent(activity.part).trim(); + if (!text) { + return null; + } + + const copyJustificationText = async () => { + const result = await copyTextToClipboard(text); + return result.ok; + }; + + return ( + + ); + }, [isSortedRenderMode, isTouchContext, shareMessageAsImage, showSplitAssistantMessageActions]); + + const lastRenderableTextPartIndex = React.useMemo(() => { + if (!shouldShowStandaloneMessageActions) { + return -1; + } + + let lastIndex = -1; + for (let index = 0; index < visibleParts.length; index += 1) { + const part = visibleParts[index]; + if (!part || part.type !== 'text') { + continue; + } + if (shouldDeferSortedInlineText) { + continue; + } + const activity = activityByPart.get(part); + if (activity?.kind === 'justification') { + continue; + } + lastIndex = index; + } + + return lastIndex; + }, [activityByPart, shouldDeferSortedInlineText, shouldShowStandaloneMessageActions, visibleParts]); + + const shouldRenderStandaloneActionsAfterContent = shouldShowStandaloneMessageActions && lastRenderableTextPartIndex < 0; const renderedParts = React.useMemo(() => { @@ -1219,6 +1452,7 @@ const AssistantMessageBody = React.memo(({ animateRows={animateActivityRows} animatedToolIds={animatedToolIdsLookup} diffStats={turnGroupingContext.diffStats} + renderJustificationActions={renderJustificationActions} /> ); @@ -1244,16 +1478,26 @@ const AssistantMessageBody = React.memo(({ continue; } rendered.push( - +
+ +
); + if (shouldShowStandaloneMessageActions && i === lastRenderableTextPartIndex) { + rendered.push( +
+
+ {messageActionButtons} +
+
+ ); + } i++; continue; } @@ -1375,12 +1619,16 @@ const AssistantMessageBody = React.memo(({ isMobile, isActivityOwnerMessage, isSortedRenderMode, + lastRenderableTextPartIndex, messageId, + messageActionButtons, + renderJustificationActions, sessionId, onContentChange, onShowPopup, onToggleTool, shouldRenderActivityGroup, + shouldShowStandaloneMessageActions, shouldShowTool, streamPhase, showReasoningTraces, @@ -1391,12 +1639,6 @@ const AssistantMessageBody = React.memo(({ visibleParts, ]); - // With flat rendering, no collapsed summary is needed — text renders inline. - - const showErrorMessage = Boolean(errorMessage); - - const shouldShowFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage)); - const turnDurationText = React.useMemo(() => { if (!isLastAssistantInTurn || !hasStopFinish) return undefined; const userCreatedAt = turnGroupingContext?.userMessageCreatedAt; @@ -1417,158 +1659,71 @@ const AssistantMessageBody = React.memo(({ const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1'; - const footerButtons = ( - <> - {onCopyMessage && ( - - - - - {t('chat.messageBody.actions.copyAnswer')} - - )} - - - - - {isSharing ? t('chat.messageBody.actions.savingImage') : t('chat.messageBody.actions.saveAsImage')} - - {!isVSCodeRuntime() ? ( - - - - - {t('chat.messageBody.actions.saveAsPlan')} - - ) : null} + const finalTurnActionButtons = ( + <> + {!isVSCodeRuntime() ? ( - - {t('chat.messageBody.actions.startNewSession')} - - - - - - {t('chat.messageBody.actions.startNewMultiRun')} - - - {showMessageTTSButtons && hasCopyableText && ( - - - - - {readAloudTooltip} - - )} - - ); + type="button" + size="icon" + variant="ghost" + disabled={!hasCopyableText || !currentProjectRef} + className={cn( + 'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50', + (!hasCopyableText || !currentProjectRef) && 'opacity-50' + )} + onPointerDown={(event) => event.stopPropagation()} + onClick={handleSaveAsPlanClick} + > + + + + {t('chat.messageBody.actions.saveAsPlan')} + + ) : null} + + + + + {t('chat.messageBody.actions.startNewSession')} + + + + + + {t('chat.messageBody.actions.startNewMultiRun')} + + + ); return (
- {shouldShowFooter && ( + {shouldRenderStandaloneActionsAfterContent && ( +
+
+ {messageActionButtons} +
+
+ )} + {shouldShowTurnFooter && (
-
- {footerButtons} +
+ {messageActionButtons} + {finalTurnActionButtons}
{turnDurationText ? ( diff --git a/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx b/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx index f7c05b04..41d7b74c 100644 --- a/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx +++ b/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx @@ -23,12 +23,14 @@ interface JustificationBlockProps { part: Part; messageId: string; onContentChange?: (reason?: ContentChangeReason) => void; + actions?: React.ReactNode; } const JustificationBlock: React.FC = ({ part, messageId, onContentChange, + actions, }) => { const chatRenderMode = useUIStore((state) => state.chatRenderMode); const partWithText = part as PartWithText; @@ -49,6 +51,7 @@ const JustificationBlock: React.FC = ({ blockId={part.id || `${messageId}-justification`} time={time} showDuration={chatRenderMode !== 'sorted'} + actions={actions} /> ); }; diff --git a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx index c8813c02..806aeee1 100644 --- a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx +++ b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx @@ -37,6 +37,7 @@ interface ProgressiveGroupProps { showHeader: boolean; animateRows?: boolean; animatedToolIds?: Set; + renderJustificationActions?: (activity: TurnActivityPart) => React.ReactNode; } const isActivityRunning = (activity: TurnActivityPart): boolean => { @@ -754,15 +755,17 @@ const InlineReasoningBlock = React.memo(({ activity, onContentChange }: { /** * Inline justification text block — rendered as normal assistant text between tools. */ -const InlineJustificationBlock = React.memo(({ activity, onContentChange }: { +const InlineJustificationBlock = React.memo(({ activity, onContentChange, actions }: { activity: TurnActivityPart; onContentChange?: (reason?: ContentChangeReason) => void; + actions?: React.ReactNode; }) => { return ( ); }); @@ -782,6 +785,7 @@ const ProgressiveGroup: React.FC = ({ showHeader, animateRows = true, animatedToolIds, + renderJustificationActions, }) => { void _streamPhase; const previewCount = showHeader && !isExpanded @@ -849,6 +853,7 @@ const ProgressiveGroup: React.FC = ({ ); diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index bb1298c8..dd6dee6e 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -81,6 +81,7 @@ type ReasoningTimelineBlockProps = { time?: { start?: number; end?: number }; showDuration?: boolean; isStreaming?: boolean; + actions?: React.ReactNode; }; export const ReasoningTimelineBlock: React.FC = ({ @@ -91,6 +92,7 @@ export const ReasoningTimelineBlock: React.FC = ({ time, showDuration = true, isStreaming = false, + actions, }) => { const [isExpanded, setIsExpanded] = React.useState(false); @@ -111,7 +113,7 @@ export const ReasoningTimelineBlock: React.FC = ({ } return ( -
+
= ({ outerClassName="max-h-80" className="p-0" > - +
+ +
+ {actions ? ( +
+
+ {actions} +
+
+ ) : null}
)} diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 4c30f99e..ff09e3c9 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -128,7 +128,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 1b348b55..643ed74a 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -220,7 +220,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' return mode === 'markdown' ? 'markdown' : 'plain'; }; -export type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage'; +export type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'stickyUserHeader' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage'; interface OpenChamberVisualSettingsProps { /** Which settings to show. If undefined, shows all. */ @@ -280,6 +280,8 @@ export const OpenChamberVisualSettings: React.FC const setTimeFormatPreference = useUIStore(state => state.setTimeFormatPreference); const weekStartPreference = useUIStore(state => state.weekStartPreference); const setWeekStartPreference = useUIStore(state => state.setWeekStartPreference); + const showSplitAssistantMessageActions = useUIStore(state => state.showSplitAssistantMessageActions); + const setShowSplitAssistantMessageActions = useUIStore(state => state.setShowSplitAssistantMessageActions); const showMobileSessionStatusBar = useUIStore(state => state.showMobileSessionStatusBar); const setShowMobileSessionStatusBar = useUIStore(state => state.setShowMobileSessionStatusBar); const messageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport); @@ -364,6 +366,11 @@ export const OpenChamberVisualSettings: React.FC void updateDesktopSettings({ stickyUserHeader: enabled }); }, [setStickyUserHeader]); + const handleShowSplitAssistantMessageActionsChange = React.useCallback((enabled: boolean) => { + setShowSplitAssistantMessageActions(enabled); + void updateDesktopSettings({ showSplitAssistantMessageActions: enabled }); + }, [setShowSplitAssistantMessageActions]); + const handleInputSpellcheckChange = React.useCallback((enabled: boolean) => { setInputSpellcheckEnabled(enabled); void updateDesktopSettings({ inputSpellcheckEnabled: enabled }); @@ -462,6 +469,7 @@ export const OpenChamberVisualSettings: React.FC || shouldShow('messageTransport') || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted') || shouldShow('stickyUserHeader') + || shouldShow('splitAssistantMessageActions') || shouldShow('diffLayout') || (shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') @@ -1443,7 +1451,7 @@ export const OpenChamberVisualSettings: React.FC
)} - {(shouldShow('stickyUserHeader') || (shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && ( + {(shouldShow('stickyUserHeader') || shouldShow('splitAssistantMessageActions') || (shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
{shouldShow('reasoning') && (
)} + {shouldShow('splitAssistantMessageActions') && ( +
handleShowSplitAssistantMessageActionsChange(!showSplitAssistantMessageActions)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + handleShowSplitAssistantMessageActionsChange(!showSplitAssistantMessageActions); + } + }} + > + +
+ {t('settings.openchamber.visual.field.showSplitAssistantMessageActions')} + + + + + + {t('settings.openchamber.visual.field.showSplitAssistantMessageActionsTooltip')} + + +
+
+ )} + {shouldShow('showToolFileIcons') && (
{ if (typeof settings.stickyUserHeader === 'boolean' && settings.stickyUserHeader !== store.stickyUserHeader) { store.setStickyUserHeader(settings.stickyUserHeader); } + if ( + typeof settings.showSplitAssistantMessageActions === 'boolean' + && settings.showSplitAssistantMessageActions !== store.showSplitAssistantMessageActions + ) { + store.setShowSplitAssistantMessageActions(settings.showSplitAssistantMessageActions); + } if (typeof settings.reportUsage === 'boolean' && settings.reportUsage !== store.reportUsage) { store.setReportUsage(settings.reportUsage); } @@ -853,6 +859,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.stickyUserHeader === 'boolean') { result.stickyUserHeader = candidate.stickyUserHeader; } + if (typeof candidate.showSplitAssistantMessageActions === 'boolean') { + result.showSplitAssistantMessageActions = candidate.showSplitAssistantMessageActions; + } if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) { result.fontSize = candidate.fontSize; } diff --git a/packages/ui/src/lib/settings/metadata.ts b/packages/ui/src/lib/settings/metadata.ts index fe70df60..91e7c631 100644 --- a/packages/ui/src/lib/settings/metadata.ts +++ b/packages/ui/src/lib/settings/metadata.ts @@ -151,7 +151,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [ title: 'Chat', group: 'general', kind: 'single', - keywords: ['tools', 'diff', 'reasoning', 'dotfiles', 'draft', 'queue', 'output'], + keywords: ['tools', 'diff', 'reasoning', 'dotfiles', 'draft', 'queue', 'output', 'copy', 'image', 'split messages', 'message actions'], }, { slug: 'shortcuts', diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 76f74e4d..9468ae49 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -567,6 +567,7 @@ interface UIStore { mermaidRenderingMode: MermaidRenderingMode; userMessageRenderingMode: UserMessageRenderingMode; stickyUserHeader: boolean; + showSplitAssistantMessageActions: boolean; showMobileSessionStatusBar: boolean; isMobileSessionStatusBarCollapsed: boolean; isExpandedInput: boolean; @@ -684,6 +685,7 @@ interface UIStore { setMermaidRenderingMode: (value: MermaidRenderingMode) => void; setUserMessageRenderingMode: (value: UserMessageRenderingMode) => void; setStickyUserHeader: (value: boolean) => void; + setShowSplitAssistantMessageActions: (value: boolean) => void; setShowMobileSessionStatusBar: (value: boolean) => void; setIsMobileSessionStatusBarCollapsed: (value: boolean) => void; viewPagerPage: 'left' | 'center' | 'right'; @@ -805,6 +807,7 @@ export const useUIStore = create()( mermaidRenderingMode: 'svg', userMessageRenderingMode: 'markdown', stickyUserHeader: true, + showSplitAssistantMessageActions: false, showMobileSessionStatusBar: true, isMobileSessionStatusBarCollapsed: false, isExpandedInput: false, @@ -1743,6 +1746,9 @@ export const useUIStore = create()( setStickyUserHeader: (value) => { set({ stickyUserHeader: value }); }, + setShowSplitAssistantMessageActions: (value) => { + set({ showSplitAssistantMessageActions: value }); + }, setShowMobileSessionStatusBar: (value) => { set({ showMobileSessionStatusBar: value }); }, @@ -1943,6 +1949,7 @@ export const useUIStore = create()( mermaidRenderingMode: state.mermaidRenderingMode, userMessageRenderingMode: state.userMessageRenderingMode, stickyUserHeader: state.stickyUserHeader, + showSplitAssistantMessageActions: state.showSplitAssistantMessageActions, showMobileSessionStatusBar: state.showMobileSessionStatusBar, isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed, shortcutOverrides: state.shortcutOverrides, diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 913cb219..d6a5584a 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -373,6 +373,9 @@ export const createSettingsHelpers = (dependencies) => { if (typeof candidate.stickyUserHeader === 'boolean') { result.stickyUserHeader = candidate.stickyUserHeader; } + if (typeof candidate.showSplitAssistantMessageActions === 'boolean') { + result.showSplitAssistantMessageActions = candidate.showSplitAssistantMessageActions; + } if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) { result.fontSize = Math.max(50, Math.min(200, Math.round(candidate.fontSize))); }