Improve assistant message action placement for split responses (#1032)

* Improve assistant message action placement for split responses

* Add toggle for split assistant message actions

* Address split message actions review feedback

* Fix inline assistant actions

---------

Co-authored-by: vhqtvn <8930337+vhqtvn@users.noreply.github.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
vhqtvn
2026-04-27 11:41:14 +03:00
committed by GitHub
co-authored by vhqtvn Bohdan Triapitsyn
parent b0dd3b52b9
commit 0bb30887b7
20 changed files with 544 additions and 278 deletions
@@ -735,11 +735,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const handleCopyMessage = React.useCallback(async () => { const handleCopyMessage = React.useCallback(async () => {
const result = await copyTextToClipboard(messageTextContent); const result = await copyTextToClipboard(messageTextContent);
if (!result.ok) { if (!result.ok) {
return; return false;
} }
setCopiedMessage(true); if (isUser) {
setTimeout(() => setCopiedMessage(false), 2000); setCopiedMessage(true);
}, [messageTextContent]); setTimeout(() => setCopiedMessage(false), 2000);
}
return true;
}, [isUser, messageTextContent]);
const handleRevert = React.useCallback(() => { const handleRevert = React.useCallback(() => {
if (!sessionId || !message.info.id) return; if (!sessionId || !message.info.id) return;
@@ -28,6 +28,7 @@ interface TurnActivityProps {
animateRows?: boolean; animateRows?: boolean;
animatedToolIds?: Set<string>; animatedToolIds?: Set<string>;
diffStats?: DiffStats; diffStats?: DiffStats;
renderJustificationActions?: (activity: TurnActivityRecord) => React.ReactNode;
} }
const TurnActivity: React.FC<TurnActivityProps> = (props) => { const TurnActivity: React.FC<TurnActivityProps> = (props) => {
@@ -46,6 +46,7 @@ import { useI18n } from '@/lib/i18n';
const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' }; 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 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 SubtaskPartLike = Part & {
type: 'subtask'; type: 'subtask';
@@ -292,7 +293,7 @@ interface MessageBodyProps {
shouldShowHeader?: boolean; shouldShowHeader?: boolean;
hasTextContent?: boolean; hasTextContent?: boolean;
onCopyMessage?: () => void; onCopyMessage?: () => void | boolean | Promise<void | boolean>;
copiedMessage?: boolean; copiedMessage?: boolean;
onAuxiliaryContentComplete?: () => void; onAuxiliaryContentComplete?: () => void;
showReasoningTraces?: boolean; showReasoningTraces?: boolean;
@@ -577,6 +578,253 @@ const UserMessageBody = React.memo(({ messageId, parts, isMobile, hasTouchInput,
); );
}); });
interface AssistantMessageActionButtonsProps {
hasCopyableText: boolean;
isTouchContext: boolean;
onCopyMessage?: () => void | boolean | Promise<void | boolean>;
onShareImage: (sourceElement?: HTMLElement | null) => Promise<void>;
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<number | null>(null);
const copiedResetTimeoutRef = React.useRef<number | null>(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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<HTMLElement>('[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<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
if (isTTSPlaying) {
stopTTS();
return;
}
if (ttsText.trim()) {
void playTTS(ttsText);
}
},
[isTTSPlaying, playTTS, stopTTS, ttsText]
);
return (
<>
{onCopyMessage && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
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 && 'opacity-50'
)}
disabled={!hasCopyableText}
aria-label={t('chat.messageBody.actions.copyMessageAria')}
aria-hidden={!hasCopyableText}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
void handleCopyButtonClick(event);
}}
onFocus={() => {
if (hasCopyableText) {
setCopyHintVisible(true);
}
}}
onBlur={() => {
if (!isMessageCopied) {
setCopyHintVisible(false);
}
}}
>
{isMessageCopied ? (
<RiCheckLine className="h-3.5 w-3.5 text-[color:var(--status-success)]" />
) : (
<RiFileCopyLine className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.copyAnswer')}</TooltipContent>
</Tooltip>
)}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
disabled={isSharing || !hasCopyableText}
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 || isSharing) && 'opacity-50'
)}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
void handleShareImageClick(event);
}}
>
{isSharing ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiImageDownloadLine className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{isSharing ? t('chat.messageBody.actions.savingImage') : t('chat.messageBody.actions.saveAsImage')}</TooltipContent>
</Tooltip>
{showMessageTTSButtons && hasCopyableText && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
'h-8 w-8 bg-transparent hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
isTTSPlaying ? 'text-green-500' : 'text-muted-foreground hover:text-foreground'
)}
aria-label={isTTSPlaying ? t('chat.messageBody.tts.stopSpeaking') : t('chat.messageBody.tts.readAloud')}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleTTSClick}
>
{isTTSPlaying ? (
<RiStopLine className="h-3.5 w-3.5" />
) : (
<RiVolumeUpLine className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{readAloudTooltip}</TooltipContent>
</Tooltip>
)}
</>
);
});
const AssistantMessageBody = React.memo(({ const AssistantMessageBody = React.memo(({
sessionId, sessionId,
messageId, messageId,
@@ -597,7 +845,6 @@ const AssistantMessageBody = React.memo(({
onContentChange, onContentChange,
hasTextContent = false, hasTextContent = false,
onCopyMessage, onCopyMessage,
copiedMessage = false,
onAuxiliaryContentComplete, onAuxiliaryContentComplete,
showReasoningTraces = false, showReasoningTraces = false,
turnGroupingContext, turnGroupingContext,
@@ -606,17 +853,14 @@ const AssistantMessageBody = React.memo(({
const { t } = useI18n(); const { t } = useI18n();
const streamPhase = _streamPhase; const streamPhase = _streamPhase;
void _allowAnimation; void _allowAnimation;
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
const copyHintTimeoutRef = React.useRef<number | null>(null);
const messageContentRef = React.useRef<HTMLDivElement>(null); const messageContentRef = React.useRef<HTMLDivElement>(null);
const messageTextContentRef = React.useRef<HTMLDivElement>(null);
const toolRevealReadyRef = React.useRef(false); const toolRevealReadyRef = React.useRef(false);
React.useEffect(() => { React.useEffect(() => {
toolRevealReadyRef.current = true; toolRevealReadyRef.current = true;
}, []); }, []);
const canCopyMessage = Boolean(onCopyMessage);
const isMessageCopied = Boolean(copiedMessage);
const isTouchContext = Boolean(hasTouchInput ?? isMobile); const isTouchContext = Boolean(hasTouchInput ?? isMobile);
const awaitingMessageCompletion = !isMessageCompleted; const awaitingMessageCompletion = !isMessageCompleted;
const animateActivityRows = awaitingMessageCompletion || Boolean(turnGroupingContext?.isWorking); const animateActivityRows = awaitingMessageCompletion || Boolean(turnGroupingContext?.isWorking);
@@ -725,24 +969,12 @@ const AssistantMessageBody = React.memo(({
const [isPlanDialogOpen, setIsPlanDialogOpen] = React.useState(false); const [isPlanDialogOpen, setIsPlanDialogOpen] = React.useState(false);
const [isSavingPlan, setIsSavingPlan] = React.useState(false); const [isSavingPlan, setIsSavingPlan] = React.useState(false);
const chatRenderMode = useUIStore((state) => state.chatRenderMode); const chatRenderMode = useUIStore((state) => state.chatRenderMode);
const showSplitAssistantMessageActions = useUIStore((state) => state.showSplitAssistantMessageActions);
const isSortedRenderMode = chatRenderMode === 'sorted'; const isSortedRenderMode = chatRenderMode === 'sorted';
const collapsedPreviewCount = 7; const collapsedPreviewCount = 7;
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false; const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
const hasStopFinish = messageFinish === 'stop'; 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(() => { const currentSession = React.useMemo(() => {
if (!currentSessionId) { if (!currentSessionId) {
return null; return null;
@@ -881,50 +1113,6 @@ const AssistantMessageBody = React.memo(({
const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion; 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<HTMLButtonElement>) => {
if (!onCopyMessage || !hasCopyableText) {
return;
}
event.stopPropagation();
event.preventDefault();
onCopyMessage();
if (isTouchContext) {
revealCopyHint();
}
},
[hasCopyableText, isTouchContext, onCopyMessage, revealCopyHint]
);
const handleForkClick = React.useCallback( const handleForkClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => { (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation(); event.stopPropagation();
@@ -952,23 +1140,6 @@ const AssistantMessageBody = React.memo(({
[assistantPlanText, openMultiRunLauncherWithPrompt] [assistantPlanText, openMultiRunLauncherWithPrompt]
); );
const handleTTSClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
if (isTTSPlaying) {
stopTTS();
return;
}
if (assistantPlanText.trim()) {
void playTTS(assistantPlanText);
}
},
[assistantPlanText, isTTSPlaying, playTTS, stopTTS]
);
const handleSaveAsPlanClick = React.useCallback( const handleSaveAsPlanClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => { (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation(); event.stopPropagation();
@@ -1013,19 +1184,14 @@ const AssistantMessageBody = React.memo(({
[assistantPlanText, currentProjectRef, t] [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<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
if (!messageContentRef.current || isSharing) return;
setIsSharing(true);
let wrapper: HTMLDivElement | null = null; let wrapper: HTMLDivElement | null = null;
try { try {
const originalElement = messageContentRef.current; const originalElement = sourceElement;
const computedStyle = window.getComputedStyle(originalElement); const computedStyle = window.getComputedStyle(originalElement);
const rootStyle = window.getComputedStyle(document.documentElement); const rootStyle = window.getComputedStyle(document.documentElement);
const resolvedBackgroundColor = const resolvedBackgroundColor =
@@ -1048,6 +1214,15 @@ const AssistantMessageBody = React.memo(({
contain: none; contain: none;
`; `;
const actionRows = clone.querySelectorAll<HTMLElement>('[data-message-actions="true"]');
actionRows.forEach((row) => {
row.style.display = 'none';
});
const actionGroups = clone.querySelectorAll<HTMLElement>('[data-message-action-group="true"]');
actionGroups.forEach((group) => {
group.style.display = 'none';
});
const timestampElements = clone.querySelectorAll<HTMLElement>('[aria-label^="Message time:"]'); const timestampElements = clone.querySelectorAll<HTMLElement>('[aria-label^="Message time:"]');
const footerRowsAdjusted = new Set<HTMLElement>(); const footerRowsAdjusted = new Set<HTMLElement>();
timestampElements.forEach((element) => { timestampElements.forEach((element) => {
@@ -1064,12 +1239,10 @@ const AssistantMessageBody = React.memo(({
const metaGroup = element.parentElement; const metaGroup = element.parentElement;
const footerRow = metaGroup?.parentElement as HTMLElement | null; const footerRow = metaGroup?.parentElement as HTMLElement | null;
const actionsGroup = footerRow?.firstElementChild as HTMLElement | null; if (!footerRow || footerRowsAdjusted.has(footerRow)) {
if (!footerRow || !actionsGroup || actionsGroup === metaGroup || footerRowsAdjusted.has(footerRow)) {
return; return;
} }
actionsGroup.style.display = 'none';
footerRow.style.justifyContent = 'flex-start'; footerRow.style.justifyContent = 'flex-start';
footerRowsAdjusted.add(footerRow); footerRowsAdjusted.add(footerRow);
}); });
@@ -1120,18 +1293,11 @@ const AssistantMessageBody = React.memo(({
if (wrapper && wrapper.parentNode) { if (wrapper && wrapper.parentNode) {
wrapper.parentNode.removeChild(wrapper); wrapper.parentNode.removeChild(wrapper);
} }
setIsSharing(false);
} }
}, },
[messageId, isSharing, t] [messageId, t]
); );
React.useEffect(() => {
return () => {
clearCopyHintTimeout();
};
}, [clearCopyHintTimeout]);
const activityPartsForTurn = React.useMemo(() => { const activityPartsForTurn = React.useMemo(() => {
const all = turnGroupingContext?.activityParts; const all = turnGroupingContext?.activityParts;
if (!isSortedRenderMode || !all) { if (!isSortedRenderMode || !all) {
@@ -1188,6 +1354,73 @@ const AssistantMessageBody = React.memo(({
&& Boolean(toggleActivityGroup); && Boolean(toggleActivityGroup);
const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish; 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(() => (
<AssistantMessageActionButtons
hasCopyableText={hasCopyableText}
isTouchContext={isTouchContext}
onCopyMessage={onCopyMessage}
onShareImage={shareMessageAsImage}
ttsText={assistantPlanText}
/>
), [assistantPlanText, hasCopyableText, isTouchContext, onCopyMessage, shareMessageAsImage]);
const renderJustificationActions = React.useCallback((activity: NonNullable<TurnGroupingContext['activityParts']>[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 (
<AssistantMessageActionButtons
hasCopyableText={true}
isTouchContext={isTouchContext}
onCopyMessage={copyJustificationText}
onShareImage={shareMessageAsImage}
ttsText={text}
/>
);
}, [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(() => { const renderedParts = React.useMemo(() => {
@@ -1219,6 +1452,7 @@ const AssistantMessageBody = React.memo(({
animateRows={animateActivityRows} animateRows={animateActivityRows}
animatedToolIds={animatedToolIdsLookup} animatedToolIds={animatedToolIdsLookup}
diffStats={turnGroupingContext.diffStats} diffStats={turnGroupingContext.diffStats}
renderJustificationActions={renderJustificationActions}
/> />
</div> </div>
); );
@@ -1244,16 +1478,26 @@ const AssistantMessageBody = React.memo(({
continue; continue;
} }
rendered.push( rendered.push(
<AssistantTextPart <div key={`assistant-text-${messageId}-${i}`} ref={messageTextContentRef} data-message-text-export-source="true">
key={`assistant-text-${messageId}-${i}`} <AssistantTextPart
part={part} part={part}
sessionId={sessionId} sessionId={sessionId}
messageId={messageId} messageId={messageId}
streamPhase={streamPhase} streamPhase={streamPhase}
chatRenderMode={chatRenderMode} chatRenderMode={chatRenderMode}
onContentChange={onContentChange} onContentChange={onContentChange}
/> />
</div>
); );
if (shouldShowStandaloneMessageActions && i === lastRenderableTextPartIndex) {
rendered.push(
<div key={`message-actions-${messageId}`} className={INLINE_MESSAGE_ACTIONS_CLASS_NAME} data-message-actions="true">
<div className="flex items-center gap-1.5" data-message-action-group="true">
{messageActionButtons}
</div>
</div>
);
}
i++; i++;
continue; continue;
} }
@@ -1375,12 +1619,16 @@ const AssistantMessageBody = React.memo(({
isMobile, isMobile,
isActivityOwnerMessage, isActivityOwnerMessage,
isSortedRenderMode, isSortedRenderMode,
lastRenderableTextPartIndex,
messageId, messageId,
messageActionButtons,
renderJustificationActions,
sessionId, sessionId,
onContentChange, onContentChange,
onShowPopup, onShowPopup,
onToggleTool, onToggleTool,
shouldRenderActivityGroup, shouldRenderActivityGroup,
shouldShowStandaloneMessageActions,
shouldShowTool, shouldShowTool,
streamPhase, streamPhase,
showReasoningTraces, showReasoningTraces,
@@ -1391,12 +1639,6 @@ const AssistantMessageBody = React.memo(({
visibleParts, 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(() => { const turnDurationText = React.useMemo(() => {
if (!isLastAssistantInTurn || !hasStopFinish) return undefined; if (!isLastAssistantInTurn || !hasStopFinish) return undefined;
const userCreatedAt = turnGroupingContext?.userMessageCreatedAt; 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 footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1';
const footerButtons = ( const finalTurnActionButtons = (
<> <>
{onCopyMessage && ( {!isVSCodeRuntime() ? (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
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 && 'opacity-50'
)}
disabled={!hasCopyableText}
aria-label={t('chat.messageBody.actions.copyMessageAria')}
aria-hidden={!hasCopyableText}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
onFocus={() => {
if (hasCopyableText) {
setCopyHintVisible(true);
}
}}
onBlur={() => {
if (!isMessageCopied) {
setCopyHintVisible(false);
}
}}
>
{isMessageCopied ? (
<RiCheckLine className="h-3.5 w-3.5 text-[color:var(--status-success)]" />
) : (
<RiFileCopyLine className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.copyAnswer')}</TooltipContent>
</Tooltip>
)}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
disabled={isSharing || !hasCopyableText}
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 || isSharing) && 'opacity-50'
)}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleShareImage}
>
{isSharing ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiImageDownloadLine className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{isSharing ? t('chat.messageBody.actions.savingImage') : t('chat.messageBody.actions.saveAsImage')}</TooltipContent>
</Tooltip>
{!isVSCodeRuntime() ? (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
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}
>
<RiBookletLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.saveAsPlan')}</TooltipContent>
</Tooltip>
) : null}
<Tooltip delayDuration={1000}> <Tooltip delayDuration={1000}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
type="button" type="button"
size="icon" size="icon"
variant="ghost" variant="ghost"
className="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" disabled={!hasCopyableText || !currentProjectRef}
onPointerDown={(event) => event.stopPropagation()} className={cn(
onClick={handleForkClick} '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'
<RiChatNewLine className="h-4 w-4" /> )}
</Button> onPointerDown={(event) => event.stopPropagation()}
</TooltipTrigger> onClick={handleSaveAsPlanClick}
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewSession')}</TooltipContent> >
</Tooltip> <RiBookletLine className="h-4 w-4" />
<Tooltip delayDuration={1000}> </Button>
<TooltipTrigger asChild> </TooltipTrigger>
<Button <TooltipContent sideOffset={6}>{t('chat.messageBody.actions.saveAsPlan')}</TooltipContent>
type="button" </Tooltip>
size="icon" ) : null}
variant="ghost" <Tooltip delayDuration={1000}>
className="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" <TooltipTrigger asChild>
onPointerDown={(event) => event.stopPropagation()} <Button
onClick={handleForkMultiRunClick} type="button"
> size="icon"
<ArrowsMerge className="h-4 w-4" /> variant="ghost"
</Button> className="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"
</TooltipTrigger> onPointerDown={(event) => event.stopPropagation()}
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewMultiRun')}</TooltipContent> onClick={handleForkClick}
</Tooltip> >
<RiChatNewLine className="h-4 w-4" />
{showMessageTTSButtons && hasCopyableText && ( </Button>
<Tooltip delayDuration={1000}> </TooltipTrigger>
<TooltipTrigger asChild> <TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewSession')}</TooltipContent>
<Button </Tooltip>
type="button" <Tooltip delayDuration={1000}>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
className={cn( type="button"
'h-8 w-8 bg-transparent hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50', size="icon"
isTTSPlaying ? 'text-green-500' : 'text-muted-foreground hover:text-foreground' variant="ghost"
)} className="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"
aria-label={isTTSPlaying ? t('chat.messageBody.tts.stopSpeaking') : t('chat.messageBody.tts.readAloud')} onPointerDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()} onClick={handleForkMultiRunClick}
onClick={handleTTSClick} >
> <ArrowsMerge className="h-4 w-4" />
{isTTSPlaying ? ( </Button>
<RiStopLine className="h-3.5 w-3.5" /> </TooltipTrigger>
) : ( <TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewMultiRun')}</TooltipContent>
<RiVolumeUpLine className="h-3.5 w-3.5" /> </Tooltip>
)} </>
</Button> );
</TooltipTrigger>
<TooltipContent sideOffset={6}>{readAloudTooltip}</TooltipContent>
</Tooltip>
)}
</>
);
return ( return (
<div <div
ref={messageContentRef} ref={messageContentRef}
className={cn( data-message-text-export-root="true"
className={cn(
'relative w-full group/message' 'relative w-full group/message'
)} )}
style={CONTAIN_LAYOUT_STYLE} style={CONTAIN_LAYOUT_STYLE}
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
> >
<TextSelectionMenu containerRef={messageContentRef} /> <TextSelectionMenu containerRef={messageContentRef} />
<SaveProjectPlanDialog <SaveProjectPlanDialog
@@ -1598,13 +1753,21 @@ const AssistantMessageBody = React.memo(({
)} )}
</div> </div>
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} /> <MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
{shouldShowFooter && ( {shouldRenderStandaloneActionsAfterContent && (
<div className={INLINE_MESSAGE_ACTIONS_CLASS_NAME} data-message-actions="true">
<div className="flex items-center gap-1.5" data-message-action-group="true">
{messageActionButtons}
</div>
</div>
)}
{shouldShowTurnFooter && (
<div <div
className="mt-2 mb-1 flex items-center justify-start gap-1.5" className="mt-2 mb-1 flex items-center justify-start gap-1.5"
style={MESSAGE_FOOTER_CONTAINER_STYLE} style={MESSAGE_FOOTER_CONTAINER_STYLE}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5" data-message-action-group="true">
{footerButtons} {messageActionButtons}
{finalTurnActionButtons}
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{turnDurationText ? ( {turnDurationText ? (
@@ -23,12 +23,14 @@ interface JustificationBlockProps {
part: Part; part: Part;
messageId: string; messageId: string;
onContentChange?: (reason?: ContentChangeReason) => void; onContentChange?: (reason?: ContentChangeReason) => void;
actions?: React.ReactNode;
} }
const JustificationBlock: React.FC<JustificationBlockProps> = ({ const JustificationBlock: React.FC<JustificationBlockProps> = ({
part, part,
messageId, messageId,
onContentChange, onContentChange,
actions,
}) => { }) => {
const chatRenderMode = useUIStore((state) => state.chatRenderMode); const chatRenderMode = useUIStore((state) => state.chatRenderMode);
const partWithText = part as PartWithText; const partWithText = part as PartWithText;
@@ -49,6 +51,7 @@ const JustificationBlock: React.FC<JustificationBlockProps> = ({
blockId={part.id || `${messageId}-justification`} blockId={part.id || `${messageId}-justification`}
time={time} time={time}
showDuration={chatRenderMode !== 'sorted'} showDuration={chatRenderMode !== 'sorted'}
actions={actions}
/> />
); );
}; };
@@ -37,6 +37,7 @@ interface ProgressiveGroupProps {
showHeader: boolean; showHeader: boolean;
animateRows?: boolean; animateRows?: boolean;
animatedToolIds?: Set<string>; animatedToolIds?: Set<string>;
renderJustificationActions?: (activity: TurnActivityPart) => React.ReactNode;
} }
const isActivityRunning = (activity: TurnActivityPart): boolean => { 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. * 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; activity: TurnActivityPart;
onContentChange?: (reason?: ContentChangeReason) => void; onContentChange?: (reason?: ContentChangeReason) => void;
actions?: React.ReactNode;
}) => { }) => {
return ( return (
<JustificationBlock <JustificationBlock
part={activity.part} part={activity.part}
messageId={activity.messageId} messageId={activity.messageId}
onContentChange={onContentChange} onContentChange={onContentChange}
actions={actions}
/> />
); );
}); });
@@ -782,6 +785,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
showHeader, showHeader,
animateRows = true, animateRows = true,
animatedToolIds, animatedToolIds,
renderJustificationActions,
}) => { }) => {
void _streamPhase; void _streamPhase;
const previewCount = showHeader && !isExpanded const previewCount = showHeader && !isExpanded
@@ -849,6 +853,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
<InlineJustificationBlock <InlineJustificationBlock
activity={row.activity} activity={row.activity}
onContentChange={onContentChange} onContentChange={onContentChange}
actions={renderJustificationActions?.(row.activity)}
/> />
</> </>
); );
@@ -81,6 +81,7 @@ type ReasoningTimelineBlockProps = {
time?: { start?: number; end?: number }; time?: { start?: number; end?: number };
showDuration?: boolean; showDuration?: boolean;
isStreaming?: boolean; isStreaming?: boolean;
actions?: React.ReactNode;
}; };
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
@@ -91,6 +92,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
time, time,
showDuration = true, showDuration = true,
isStreaming = false, isStreaming = false,
actions,
}) => { }) => {
const [isExpanded, setIsExpanded] = React.useState(false); const [isExpanded, setIsExpanded] = React.useState(false);
@@ -111,7 +113,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
} }
return ( return (
<div className="my-1" data-reasoning-block-id={blockId}> <div className="my-1" data-reasoning-block-id={blockId} data-message-text-export-root="true">
<div <div
className={cn( className={cn(
'group/tool flex items-center gap-2 pr-2 pl-px py-1.5 rounded-xl cursor-pointer' 'group/tool flex items-center gap-2 pr-2 pl-px py-1.5 rounded-xl cursor-pointer'
@@ -171,13 +173,22 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
outerClassName="max-h-80" outerClassName="max-h-80"
className="p-0" className="p-0"
> >
<MarkdownRenderer <div data-message-text-export-source="true">
content={text} <MarkdownRenderer
messageId={blockId} content={text}
isAnimated={false} messageId={blockId}
isStreaming={isStreaming} isAnimated={false}
variant="reasoning" isStreaming={isStreaming}
/> variant="reasoning"
/>
</div>
{actions ? (
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5" data-message-actions="true">
<div className="flex items-center gap-1.5" data-message-action-group="true">
{actions}
</div>
</div>
) : null}
</ScrollableOverlay> </ScrollableOverlay>
</div> </div>
)} )}
@@ -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 // Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft
const ChatSectionContent: React.FC = () => { const ChatSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'expandedTools', 'stickyUserHeader', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'queueMode', 'persistDraft', 'inputSpellcheck']} />; return <OpenChamberVisualSettings visibleSettings={['chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'expandedTools', 'stickyUserHeader', 'splitAssistantMessageActions', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'queueMode', 'persistDraft', 'inputSpellcheck']} />;
}; };
// Sessions section: Default model & agent, Session retention // Sessions section: Default model & agent, Session retention
@@ -220,7 +220,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? '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 { interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */ /** Which settings to show. If undefined, shows all. */
@@ -280,6 +280,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setTimeFormatPreference = useUIStore(state => state.setTimeFormatPreference); const setTimeFormatPreference = useUIStore(state => state.setTimeFormatPreference);
const weekStartPreference = useUIStore(state => state.weekStartPreference); const weekStartPreference = useUIStore(state => state.weekStartPreference);
const setWeekStartPreference = useUIStore(state => state.setWeekStartPreference); 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 showMobileSessionStatusBar = useUIStore(state => state.showMobileSessionStatusBar);
const setShowMobileSessionStatusBar = useUIStore(state => state.setShowMobileSessionStatusBar); const setShowMobileSessionStatusBar = useUIStore(state => state.setShowMobileSessionStatusBar);
const messageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport); const messageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport);
@@ -364,6 +366,11 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
void updateDesktopSettings({ stickyUserHeader: enabled }); void updateDesktopSettings({ stickyUserHeader: enabled });
}, [setStickyUserHeader]); }, [setStickyUserHeader]);
const handleShowSplitAssistantMessageActionsChange = React.useCallback((enabled: boolean) => {
setShowSplitAssistantMessageActions(enabled);
void updateDesktopSettings({ showSplitAssistantMessageActions: enabled });
}, [setShowSplitAssistantMessageActions]);
const handleInputSpellcheckChange = React.useCallback((enabled: boolean) => { const handleInputSpellcheckChange = React.useCallback((enabled: boolean) => {
setInputSpellcheckEnabled(enabled); setInputSpellcheckEnabled(enabled);
void updateDesktopSettings({ inputSpellcheckEnabled: enabled }); void updateDesktopSettings({ inputSpellcheckEnabled: enabled });
@@ -462,6 +469,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|| shouldShow('messageTransport') || shouldShow('messageTransport')
|| (shouldShow('activityRenderMode') && chatRenderMode === 'sorted') || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted')
|| shouldShow('stickyUserHeader') || shouldShow('stickyUserHeader')
|| shouldShow('splitAssistantMessageActions')
|| shouldShow('diffLayout') || shouldShow('diffLayout')
|| (shouldShow('mobileStatusBar') && isMobile) || (shouldShow('mobileStatusBar') && isMobile)
|| shouldShow('dotfiles') || shouldShow('dotfiles')
@@ -1443,7 +1451,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div> </div>
)} )}
{(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')) && (
<section className="p-2 space-y-0.5"> <section className="p-2 space-y-0.5">
{shouldShow('reasoning') && ( {shouldShow('reasoning') && (
<div <div
@@ -1491,6 +1499,39 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div> </div>
)} )}
{shouldShow('splitAssistantMessageActions') && (
<div
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
aria-pressed={showSplitAssistantMessageActions}
onClick={() => handleShowSplitAssistantMessageActionsChange(!showSplitAssistantMessageActions)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
handleShowSplitAssistantMessageActionsChange(!showSplitAssistantMessageActions);
}
}}
>
<Checkbox
checked={showSplitAssistantMessageActions}
onChange={handleShowSplitAssistantMessageActionsChange}
ariaLabel={t('settings.openchamber.visual.field.showSplitAssistantMessageActionsAria')}
/>
<div className="flex min-w-0 items-center gap-1.5">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.showSplitAssistantMessageActions')}</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 cursor-help text-muted-foreground/60" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.openchamber.visual.field.showSplitAssistantMessageActionsTooltip')}
</TooltipContent>
</Tooltip>
</div>
</div>
)}
{shouldShow('showToolFileIcons') && ( {shouldShow('showToolFileIcons') && (
<div <div
className="group flex cursor-pointer items-center gap-2 py-0.5" className="group flex cursor-pointer items-center gap-2 py-0.5"
+1
View File
@@ -572,6 +572,7 @@ export interface SettingsPayload {
messageStreamTransport?: 'auto' | 'ws' | 'sse'; messageStreamTransport?: 'auto' | 'ws' | 'sse';
activityRenderMode?: 'collapsed' | 'summary'; activityRenderMode?: 'collapsed' | 'summary';
mermaidRenderingMode?: 'svg' | 'ascii'; mermaidRenderingMode?: 'svg' | 'ascii';
showSplitAssistantMessageActions?: boolean;
fontSize?: number; fontSize?: number;
terminalFontSize?: number; terminalFontSize?: number;
uiFont?: string; uiFont?: string;
+1
View File
@@ -133,6 +133,7 @@ export type DesktopSettings = {
mermaidRenderingMode?: 'svg' | 'ascii'; mermaidRenderingMode?: 'svg' | 'ascii';
userMessageRenderingMode?: 'markdown' | 'plain'; userMessageRenderingMode?: 'markdown' | 'plain';
stickyUserHeader?: boolean; stickyUserHeader?: boolean;
showSplitAssistantMessageActions?: boolean;
fontSize?: number; fontSize?: number;
terminalFontSize?: number; terminalFontSize?: number;
uiFont?: string; uiFont?: string;
@@ -1394,6 +1394,9 @@ export const settingsDict = {
'settings.openchamber.visual.field.showReasoningTraces': 'Show Reasoning Traces', 'settings.openchamber.visual.field.showReasoningTraces': 'Show Reasoning Traces',
'settings.openchamber.visual.field.stickyUserHeaderAria': 'Sticky user header', 'settings.openchamber.visual.field.stickyUserHeaderAria': 'Sticky user header',
'settings.openchamber.visual.field.stickyUserHeader': 'Sticky User Header', 'settings.openchamber.visual.field.stickyUserHeader': 'Sticky User Header',
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': 'Inline assistant actions',
'settings.openchamber.visual.field.showSplitAssistantMessageActions': 'Inline Assistant Actions',
'settings.openchamber.visual.field.showSplitAssistantMessageActionsTooltip': 'Show Copy Answer, Save as image, and Read aloud on assistant text blocks that appear before later tool calls in the same response.',
'settings.openchamber.visual.field.showToolFileIconsAria': 'Show tool file icons', 'settings.openchamber.visual.field.showToolFileIconsAria': 'Show tool file icons',
'settings.openchamber.visual.field.showToolFileIcons': 'Show Tool File Icons', 'settings.openchamber.visual.field.showToolFileIcons': 'Show Tool File Icons',
'settings.openchamber.visual.field.showMobileStatusBarAria': 'Show mobile status bar', 'settings.openchamber.visual.field.showMobileStatusBarAria': 'Show mobile status bar',
@@ -1394,6 +1394,9 @@ export const settingsDict = {
"settings.openchamber.visual.field.showReasoningTraces": "Mostrar trazas de razonamiento", "settings.openchamber.visual.field.showReasoningTraces": "Mostrar trazas de razonamiento",
"settings.openchamber.visual.field.stickyUserHeaderAria": "Encabezado del usuario fijo", "settings.openchamber.visual.field.stickyUserHeaderAria": "Encabezado del usuario fijo",
"settings.openchamber.visual.field.stickyUserHeader": "Encabezado de usuario fijo", "settings.openchamber.visual.field.stickyUserHeader": "Encabezado de usuario fijo",
"settings.openchamber.visual.field.showSplitAssistantMessageActionsAria": "Acciones en línea del asistente",
"settings.openchamber.visual.field.showSplitAssistantMessageActions": "Acciones en línea del asistente",
"settings.openchamber.visual.field.showSplitAssistantMessageActionsTooltip": "Muestra Copiar respuesta, Guardar como imagen y Leer en voz alta en bloques de texto del asistente que aparecen antes de llamadas a herramientas posteriores dentro de la misma respuesta.",
"settings.openchamber.visual.field.showToolFileIconsAria": "Mostrar iconos de archivos de herramientas", "settings.openchamber.visual.field.showToolFileIconsAria": "Mostrar iconos de archivos de herramientas",
"settings.openchamber.visual.field.showToolFileIcons": "Mostrar iconos de archivos de herramientas", "settings.openchamber.visual.field.showToolFileIcons": "Mostrar iconos de archivos de herramientas",
"settings.openchamber.visual.field.showMobileStatusBarAria": "Mostrar barra de estado móvil", "settings.openchamber.visual.field.showMobileStatusBarAria": "Mostrar barra de estado móvil",
@@ -1394,6 +1394,9 @@ export const settingsDict = {
'settings.openchamber.visual.field.showReasoningTraces': 'Reasoning Trace 표시', 'settings.openchamber.visual.field.showReasoningTraces': 'Reasoning Trace 표시',
'settings.openchamber.visual.field.stickyUserHeaderAria': '고정 사용자 헤더', 'settings.openchamber.visual.field.stickyUserHeaderAria': '고정 사용자 헤더',
'settings.openchamber.visual.field.stickyUserHeader': '고정 사용자 헤더', 'settings.openchamber.visual.field.stickyUserHeader': '고정 사용자 헤더',
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': '인라인 어시스턴트 작업',
'settings.openchamber.visual.field.showSplitAssistantMessageActions': '인라인 어시스턴트 작업',
'settings.openchamber.visual.field.showSplitAssistantMessageActionsTooltip': '같은 응답에서 이후 도구 호출 앞에 나타나는 어시스턴트 텍스트 블록에 답변 복사, 이미지로 저장, 소리 내어 읽기 작업을 표시합니다.',
'settings.openchamber.visual.field.showToolFileIconsAria': '도구 파일 아이콘 표시', 'settings.openchamber.visual.field.showToolFileIconsAria': '도구 파일 아이콘 표시',
'settings.openchamber.visual.field.showToolFileIcons': '도구 파일 아이콘 표시', 'settings.openchamber.visual.field.showToolFileIcons': '도구 파일 아이콘 표시',
'settings.openchamber.visual.field.showMobileStatusBarAria': '모바일 상태 바 표시', 'settings.openchamber.visual.field.showMobileStatusBarAria': '모바일 상태 바 표시',
@@ -1394,6 +1394,9 @@ export const settingsDict = {
"settings.openchamber.visual.field.showReasoningTraces": "Mostrar rastros de raciocínio", "settings.openchamber.visual.field.showReasoningTraces": "Mostrar rastros de raciocínio",
"settings.openchamber.visual.field.stickyUserHeaderAria": "Cabeçalho do usuário fixo", "settings.openchamber.visual.field.stickyUserHeaderAria": "Cabeçalho do usuário fixo",
"settings.openchamber.visual.field.stickyUserHeader": "Cabeçalho do usuário fixo", "settings.openchamber.visual.field.stickyUserHeader": "Cabeçalho do usuário fixo",
"settings.openchamber.visual.field.showSplitAssistantMessageActionsAria": "Ações inline do assistente",
"settings.openchamber.visual.field.showSplitAssistantMessageActions": "Ações inline do assistente",
"settings.openchamber.visual.field.showSplitAssistantMessageActionsTooltip": "Mostra Copiar resposta, Salvar como imagem e Ler em voz alta em blocos de texto do assistente que aparecem antes de chamadas de ferramenta posteriores na mesma resposta.",
"settings.openchamber.visual.field.showToolFileIconsAria": "Mostrar ícones de arquivos de ferramentas", "settings.openchamber.visual.field.showToolFileIconsAria": "Mostrar ícones de arquivos de ferramentas",
"settings.openchamber.visual.field.showToolFileIcons": "Mostrar ícones de arquivos de ferramentas", "settings.openchamber.visual.field.showToolFileIcons": "Mostrar ícones de arquivos de ferramentas",
"settings.openchamber.visual.field.showMobileStatusBarAria": "Mostrar barra de status móvel", "settings.openchamber.visual.field.showMobileStatusBarAria": "Mostrar barra de status móvel",
@@ -1394,6 +1394,9 @@ export const settingsDict = {
"settings.openchamber.visual.field.showReasoningTraces": "Показати сліди міркувань", "settings.openchamber.visual.field.showReasoningTraces": "Показати сліди міркувань",
"settings.openchamber.visual.field.stickyUserHeaderAria": "Закріплений заголовок користувача", "settings.openchamber.visual.field.stickyUserHeaderAria": "Закріплений заголовок користувача",
"settings.openchamber.visual.field.stickyUserHeader": "Закріплений заголовок користувача", "settings.openchamber.visual.field.stickyUserHeader": "Закріплений заголовок користувача",
"settings.openchamber.visual.field.showSplitAssistantMessageActionsAria": "Вбудовані дії асистента",
"settings.openchamber.visual.field.showSplitAssistantMessageActions": "Вбудовані дії асистента",
"settings.openchamber.visual.field.showSplitAssistantMessageActionsTooltip": "Показує «Скопіювати відповідь», «Зберегти як зображення» та озвучення для текстових блоків асистента, які з’являються перед пізнішими викликами інструментів у тій самій відповіді.",
"settings.openchamber.visual.field.showToolFileIconsAria": "Показати значки файлів інструментів", "settings.openchamber.visual.field.showToolFileIconsAria": "Показати значки файлів інструментів",
"settings.openchamber.visual.field.showToolFileIcons": "Показати значки файлів інструментів", "settings.openchamber.visual.field.showToolFileIcons": "Показати значки файлів інструментів",
"settings.openchamber.visual.field.showMobileStatusBarAria": "Показати рядок стану мобільного пристрою", "settings.openchamber.visual.field.showMobileStatusBarAria": "Показати рядок стану мобільного пристрою",
@@ -1394,6 +1394,9 @@ export const settingsDict = {
'settings.openchamber.visual.field.showReasoningTraces': '显示推理轨迹', 'settings.openchamber.visual.field.showReasoningTraces': '显示推理轨迹',
'settings.openchamber.visual.field.stickyUserHeaderAria': '固定用户消息头', 'settings.openchamber.visual.field.stickyUserHeaderAria': '固定用户消息头',
'settings.openchamber.visual.field.stickyUserHeader': '固定用户消息头', 'settings.openchamber.visual.field.stickyUserHeader': '固定用户消息头',
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': '内联助手操作',
'settings.openchamber.visual.field.showSplitAssistantMessageActions': '内联助手操作',
'settings.openchamber.visual.field.showSplitAssistantMessageActionsTooltip': '在同一条回复中,如果助手文本块后面还有工具调用,则显示“复制回答”“保存为图片”和“朗读”操作。',
'settings.openchamber.visual.field.showToolFileIconsAria': '显示工具文件图标', 'settings.openchamber.visual.field.showToolFileIconsAria': '显示工具文件图标',
'settings.openchamber.visual.field.showToolFileIcons': '显示工具文件图标', 'settings.openchamber.visual.field.showToolFileIcons': '显示工具文件图标',
'settings.openchamber.visual.field.showMobileStatusBarAria': '显示移动端状态栏', 'settings.openchamber.visual.field.showMobileStatusBarAria': '显示移动端状态栏',
+9
View File
@@ -431,6 +431,12 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
if (typeof settings.stickyUserHeader === 'boolean' && settings.stickyUserHeader !== store.stickyUserHeader) { if (typeof settings.stickyUserHeader === 'boolean' && settings.stickyUserHeader !== store.stickyUserHeader) {
store.setStickyUserHeader(settings.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) { if (typeof settings.reportUsage === 'boolean' && settings.reportUsage !== store.reportUsage) {
store.setReportUsage(settings.reportUsage); store.setReportUsage(settings.reportUsage);
} }
@@ -853,6 +859,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.stickyUserHeader === 'boolean') { if (typeof candidate.stickyUserHeader === 'boolean') {
result.stickyUserHeader = candidate.stickyUserHeader; result.stickyUserHeader = candidate.stickyUserHeader;
} }
if (typeof candidate.showSplitAssistantMessageActions === 'boolean') {
result.showSplitAssistantMessageActions = candidate.showSplitAssistantMessageActions;
}
if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) { if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) {
result.fontSize = candidate.fontSize; result.fontSize = candidate.fontSize;
} }
+1 -1
View File
@@ -151,7 +151,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
title: 'Chat', title: 'Chat',
group: 'general', group: 'general',
kind: 'single', 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', slug: 'shortcuts',
+7
View File
@@ -567,6 +567,7 @@ interface UIStore {
mermaidRenderingMode: MermaidRenderingMode; mermaidRenderingMode: MermaidRenderingMode;
userMessageRenderingMode: UserMessageRenderingMode; userMessageRenderingMode: UserMessageRenderingMode;
stickyUserHeader: boolean; stickyUserHeader: boolean;
showSplitAssistantMessageActions: boolean;
showMobileSessionStatusBar: boolean; showMobileSessionStatusBar: boolean;
isMobileSessionStatusBarCollapsed: boolean; isMobileSessionStatusBarCollapsed: boolean;
isExpandedInput: boolean; isExpandedInput: boolean;
@@ -684,6 +685,7 @@ interface UIStore {
setMermaidRenderingMode: (value: MermaidRenderingMode) => void; setMermaidRenderingMode: (value: MermaidRenderingMode) => void;
setUserMessageRenderingMode: (value: UserMessageRenderingMode) => void; setUserMessageRenderingMode: (value: UserMessageRenderingMode) => void;
setStickyUserHeader: (value: boolean) => void; setStickyUserHeader: (value: boolean) => void;
setShowSplitAssistantMessageActions: (value: boolean) => void;
setShowMobileSessionStatusBar: (value: boolean) => void; setShowMobileSessionStatusBar: (value: boolean) => void;
setIsMobileSessionStatusBarCollapsed: (value: boolean) => void; setIsMobileSessionStatusBarCollapsed: (value: boolean) => void;
viewPagerPage: 'left' | 'center' | 'right'; viewPagerPage: 'left' | 'center' | 'right';
@@ -805,6 +807,7 @@ export const useUIStore = create<UIStore>()(
mermaidRenderingMode: 'svg', mermaidRenderingMode: 'svg',
userMessageRenderingMode: 'markdown', userMessageRenderingMode: 'markdown',
stickyUserHeader: true, stickyUserHeader: true,
showSplitAssistantMessageActions: false,
showMobileSessionStatusBar: true, showMobileSessionStatusBar: true,
isMobileSessionStatusBarCollapsed: false, isMobileSessionStatusBarCollapsed: false,
isExpandedInput: false, isExpandedInput: false,
@@ -1743,6 +1746,9 @@ export const useUIStore = create<UIStore>()(
setStickyUserHeader: (value) => { setStickyUserHeader: (value) => {
set({ stickyUserHeader: value }); set({ stickyUserHeader: value });
}, },
setShowSplitAssistantMessageActions: (value) => {
set({ showSplitAssistantMessageActions: value });
},
setShowMobileSessionStatusBar: (value) => { setShowMobileSessionStatusBar: (value) => {
set({ showMobileSessionStatusBar: value }); set({ showMobileSessionStatusBar: value });
}, },
@@ -1943,6 +1949,7 @@ export const useUIStore = create<UIStore>()(
mermaidRenderingMode: state.mermaidRenderingMode, mermaidRenderingMode: state.mermaidRenderingMode,
userMessageRenderingMode: state.userMessageRenderingMode, userMessageRenderingMode: state.userMessageRenderingMode,
stickyUserHeader: state.stickyUserHeader, stickyUserHeader: state.stickyUserHeader,
showSplitAssistantMessageActions: state.showSplitAssistantMessageActions,
showMobileSessionStatusBar: state.showMobileSessionStatusBar, showMobileSessionStatusBar: state.showMobileSessionStatusBar,
isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed, isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed,
shortcutOverrides: state.shortcutOverrides, shortcutOverrides: state.shortcutOverrides,
@@ -373,6 +373,9 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.stickyUserHeader === 'boolean') { if (typeof candidate.stickyUserHeader === 'boolean') {
result.stickyUserHeader = candidate.stickyUserHeader; result.stickyUserHeader = candidate.stickyUserHeader;
} }
if (typeof candidate.showSplitAssistantMessageActions === 'boolean') {
result.showSplitAssistantMessageActions = candidate.showSplitAssistantMessageActions;
}
if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) { if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) {
result.fontSize = Math.max(50, Math.min(200, Math.round(candidate.fontSize))); result.fontSize = Math.max(50, Math.min(200, Math.round(candidate.fontSize)));
} }