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 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;
@@ -28,6 +28,7 @@ interface TurnActivityProps {
animateRows?: boolean;
animatedToolIds?: Set<string>;
diffStats?: DiffStats;
renderJustificationActions?: (activity: TurnActivityRecord) => React.ReactNode;
}
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 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<void | boolean>;
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<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(({
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<number | null>(null);
const messageContentRef = React.useRef<HTMLDivElement>(null);
const messageTextContentRef = React.useRef<HTMLDivElement>(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<HTMLButtonElement>) => {
if (!onCopyMessage || !hasCopyableText) {
return;
}
event.stopPropagation();
event.preventDefault();
onCopyMessage();
if (isTouchContext) {
revealCopyHint();
}
},
[hasCopyableText, isTouchContext, onCopyMessage, revealCopyHint]
);
const handleForkClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
@@ -952,23 +1140,6 @@ const AssistantMessageBody = React.memo(({
[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(
(event: React.MouseEvent<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<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 footerRowsAdjusted = new Set<HTMLElement>();
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(() => (
<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(() => {
@@ -1219,6 +1452,7 @@ const AssistantMessageBody = React.memo(({
animateRows={animateActivityRows}
animatedToolIds={animatedToolIdsLookup}
diffStats={turnGroupingContext.diffStats}
renderJustificationActions={renderJustificationActions}
/>
</div>
);
@@ -1244,16 +1478,26 @@ const AssistantMessageBody = React.memo(({
continue;
}
rendered.push(
<AssistantTextPart
key={`assistant-text-${messageId}-${i}`}
part={part}
sessionId={sessionId}
messageId={messageId}
streamPhase={streamPhase}
chatRenderMode={chatRenderMode}
onContentChange={onContentChange}
/>
<div key={`assistant-text-${messageId}-${i}`} ref={messageTextContentRef} data-message-text-export-source="true">
<AssistantTextPart
part={part}
sessionId={sessionId}
messageId={messageId}
streamPhase={streamPhase}
chatRenderMode={chatRenderMode}
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++;
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 && (
<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}
const finalTurnActionButtons = (
<>
{!isVSCodeRuntime() ? (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
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"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleForkClick}
>
<RiChatNewLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewSession')}</TooltipContent>
</Tooltip>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
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"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleForkMultiRunClick}
>
<ArrowsMerge className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewMultiRun')}</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>
)}
</>
);
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}>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
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"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleForkClick}
>
<RiChatNewLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewSession')}</TooltipContent>
</Tooltip>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
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"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleForkMultiRunClick}
>
<ArrowsMerge className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewMultiRun')}</TooltipContent>
</Tooltip>
</>
);
return (
<div
ref={messageContentRef}
className={cn(
ref={messageContentRef}
data-message-text-export-root="true"
className={cn(
'relative w-full group/message'
)}
style={CONTAIN_LAYOUT_STYLE}
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
>
<TextSelectionMenu containerRef={messageContentRef} />
<SaveProjectPlanDialog
@@ -1598,13 +1753,21 @@ const AssistantMessageBody = React.memo(({
)}
</div>
<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
className="mt-2 mb-1 flex items-center justify-start gap-1.5"
style={MESSAGE_FOOTER_CONTAINER_STYLE}
>
<div className="flex items-center gap-1.5">
{footerButtons}
<div className="flex items-center gap-1.5" data-message-action-group="true">
{messageActionButtons}
{finalTurnActionButtons}
</div>
<div className="flex items-center gap-1.5">
{turnDurationText ? (
@@ -23,12 +23,14 @@ interface JustificationBlockProps {
part: Part;
messageId: string;
onContentChange?: (reason?: ContentChangeReason) => void;
actions?: React.ReactNode;
}
const JustificationBlock: React.FC<JustificationBlockProps> = ({
part,
messageId,
onContentChange,
actions,
}) => {
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
const partWithText = part as PartWithText;
@@ -49,6 +51,7 @@ const JustificationBlock: React.FC<JustificationBlockProps> = ({
blockId={part.id || `${messageId}-justification`}
time={time}
showDuration={chatRenderMode !== 'sorted'}
actions={actions}
/>
);
};
@@ -37,6 +37,7 @@ interface ProgressiveGroupProps {
showHeader: boolean;
animateRows?: boolean;
animatedToolIds?: Set<string>;
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 (
<JustificationBlock
part={activity.part}
messageId={activity.messageId}
onContentChange={onContentChange}
actions={actions}
/>
);
});
@@ -782,6 +785,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
showHeader,
animateRows = true,
animatedToolIds,
renderJustificationActions,
}) => {
void _streamPhase;
const previewCount = showHeader && !isExpanded
@@ -849,6 +853,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
<InlineJustificationBlock
activity={row.activity}
onContentChange={onContentChange}
actions={renderJustificationActions?.(row.activity)}
/>
</>
);
@@ -81,6 +81,7 @@ type ReasoningTimelineBlockProps = {
time?: { start?: number; end?: number };
showDuration?: boolean;
isStreaming?: boolean;
actions?: React.ReactNode;
};
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
@@ -91,6 +92,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
time,
showDuration = true,
isStreaming = false,
actions,
}) => {
const [isExpanded, setIsExpanded] = React.useState(false);
@@ -111,7 +113,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
}
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
className={cn(
'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"
className="p-0"
>
<MarkdownRenderer
content={text}
messageId={blockId}
isAnimated={false}
isStreaming={isStreaming}
variant="reasoning"
/>
<div data-message-text-export-source="true">
<MarkdownRenderer
content={text}
messageId={blockId}
isAnimated={false}
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>
</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
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
@@ -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<OpenChamberVisualSettingsProps>
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<OpenChamberVisualSettingsProps>
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<OpenChamberVisualSettingsProps>
|| 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<OpenChamberVisualSettingsProps>
</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">
{shouldShow('reasoning') && (
<div
@@ -1491,6 +1499,39 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</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') && (
<div
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';
activityRenderMode?: 'collapsed' | 'summary';
mermaidRenderingMode?: 'svg' | 'ascii';
showSplitAssistantMessageActions?: boolean;
fontSize?: number;
terminalFontSize?: number;
uiFont?: string;
+1
View File
@@ -133,6 +133,7 @@ export type DesktopSettings = {
mermaidRenderingMode?: 'svg' | 'ascii';
userMessageRenderingMode?: 'markdown' | 'plain';
stickyUserHeader?: boolean;
showSplitAssistantMessageActions?: boolean;
fontSize?: number;
terminalFontSize?: number;
uiFont?: string;
@@ -1394,6 +1394,9 @@ export const settingsDict = {
'settings.openchamber.visual.field.showReasoningTraces': 'Show Reasoning Traces',
'settings.openchamber.visual.field.stickyUserHeaderAria': '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.showToolFileIcons': 'Show Tool File Icons',
'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.stickyUserHeaderAria": "Encabezado del 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.showToolFileIcons": "Mostrar iconos de archivos de herramientas",
"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.stickyUserHeaderAria': '고정 사용자 헤더',
'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.showToolFileIcons': '도구 파일 아이콘 표시',
'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.stickyUserHeaderAria": "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.showToolFileIcons": "Mostrar ícones de arquivos de ferramentas",
"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.stickyUserHeaderAria": "Закріплений заголовок користувача",
"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.showToolFileIcons": "Показати значки файлів інструментів",
"settings.openchamber.visual.field.showMobileStatusBarAria": "Показати рядок стану мобільного пристрою",
@@ -1394,6 +1394,9 @@ export const settingsDict = {
'settings.openchamber.visual.field.showReasoningTraces': '显示推理轨迹',
'settings.openchamber.visual.field.stickyUserHeaderAria': '固定用户消息头',
'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.showToolFileIcons': '显示工具文件图标',
'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) {
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;
}
+1 -1
View File
@@ -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',
+7
View File
@@ -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<UIStore>()(
mermaidRenderingMode: 'svg',
userMessageRenderingMode: 'markdown',
stickyUserHeader: true,
showSplitAssistantMessageActions: false,
showMobileSessionStatusBar: true,
isMobileSessionStatusBarCollapsed: false,
isExpandedInput: false,
@@ -1743,6 +1746,9 @@ export const useUIStore = create<UIStore>()(
setStickyUserHeader: (value) => {
set({ stickyUserHeader: value });
},
setShowSplitAssistantMessageActions: (value) => {
set({ showSplitAssistantMessageActions: value });
},
setShowMobileSessionStatusBar: (value) => {
set({ showMobileSessionStatusBar: value });
},
@@ -1943,6 +1949,7 @@ export const useUIStore = create<UIStore>()(
mermaidRenderingMode: state.mermaidRenderingMode,
userMessageRenderingMode: state.userMessageRenderingMode,
stickyUserHeader: state.stickyUserHeader,
showSplitAssistantMessageActions: state.showSplitAssistantMessageActions,
showMobileSessionStatusBar: state.showMobileSessionStatusBar,
isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed,
shortcutOverrides: state.shortcutOverrides,