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:
committed by
GitHub
co-authored by
vhqtvn
Bohdan Triapitsyn
parent
b0dd3b52b9
commit
0bb30887b7
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': '显示移动端状态栏',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user