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