mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat: add assistant response view setting (#3455)
This commit is contained in:
@@ -17,7 +17,9 @@ vi.mock('../components/SettingsHeader', () => ({
|
||||
|
||||
function renderPanel(fontSize: 'small' | 'medium' | 'large' | 'xlarge' = 'medium') {
|
||||
return renderWithProviders(<AppearancePanel />, {
|
||||
preloadedState: { theme: { mode: 'system', tabBarLabels: 'hover', fontSize } },
|
||||
preloadedState: {
|
||||
theme: { mode: 'system', tabBarLabels: 'hover', fontSize, agentMessageViewMode: 'bubbles' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,4 +47,14 @@ describe('<AppearancePanel /> font size', () => {
|
||||
|
||||
expect(store.getState().theme.fontSize).toBe('xlarge');
|
||||
});
|
||||
|
||||
it('toggles assistant text mode for chat output', () => {
|
||||
const { getByRole, store } = renderPanel('medium');
|
||||
const toggle = getByRole('switch', { name: /settings\.appearance\.assistantTextMode/ });
|
||||
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(store.getState().theme.agentMessageViewMode).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { ReactElement } from 'react';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import {
|
||||
type AgentMessageViewMode,
|
||||
type FontSize,
|
||||
setAgentMessageViewMode,
|
||||
setFontSize,
|
||||
setTabBarLabels,
|
||||
setThemeMode,
|
||||
@@ -68,11 +70,19 @@ const AppearancePanel = () => {
|
||||
const mode = useAppSelector(state => state.theme.mode);
|
||||
const fontSize = useAppSelector(state => state.theme.fontSize);
|
||||
const tabBarLabels = useAppSelector(state => state.theme.tabBarLabels);
|
||||
const agentMessageViewMode = useAppSelector(
|
||||
state => state.theme.agentMessageViewMode ?? 'bubbles'
|
||||
);
|
||||
const labelsAlwaysVisible = tabBarLabels === 'always';
|
||||
const assistantTextModeEnabled = agentMessageViewMode === 'text';
|
||||
const toggleTabBarLabels = () => {
|
||||
const next: TabBarLabels = labelsAlwaysVisible ? 'hover' : 'always';
|
||||
dispatch(setTabBarLabels(next));
|
||||
};
|
||||
const toggleAssistantTextMode = () => {
|
||||
const next: AgentMessageViewMode = assistantTextModeEnabled ? 'bubbles' : 'text';
|
||||
dispatch(setAgentMessageViewMode(next));
|
||||
};
|
||||
|
||||
// Build at render time so the labels follow the active locale; `t()` itself
|
||||
// memoises on locale change, so this stays stable across re-renders within a
|
||||
@@ -298,6 +308,40 @@ const AppearancePanel = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-500 mb-2 px-1">
|
||||
{t('settings.appearance.chatHeading')}
|
||||
</h3>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={assistantTextModeEnabled}
|
||||
onClick={toggleAssistantTextMode}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-800/60 focus:outline-none focus-visible:bg-primary-50 dark:focus-visible:bg-primary-900/30">
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('settings.appearance.assistantTextMode')}
|
||||
</span>
|
||||
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('settings.appearance.assistantTextModeDesc')}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`relative inline-flex w-10 h-6 rounded-full transition-colors flex-shrink-0 ${
|
||||
assistantTextModeEnabled ? 'bg-primary-500' : 'bg-neutral-300 dark:bg-neutral-700'
|
||||
}`}>
|
||||
<span
|
||||
className={`absolute top-0.5 inline-block w-5 h-5 rounded-full bg-white shadow transition-transform ${
|
||||
assistantTextModeEnabled ? 'translate-x-[18px]' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3858,6 +3858,10 @@ const messages: TranslationMap = {
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'إظهار التسميات دائمًا',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'عند إيقاف التشغيل، تظهر التسميات فقط عند التمرير أو لعلامة التبويب النشطة.',
|
||||
'settings.appearance.chatHeading': 'الدردشة',
|
||||
'settings.appearance.assistantTextMode': 'ردود المساعد كنص',
|
||||
'settings.appearance.assistantTextModeDesc':
|
||||
'اعرض ردود المساعد كنص بلا إطار مع إبقاء رسائلك داخل فقاعات.',
|
||||
'settings.mascot.active': 'نشط',
|
||||
'settings.mascot.characterDesc': 'وصف الشخصية',
|
||||
'settings.mascot.characterHeading': 'عنوان الشخصية',
|
||||
|
||||
@@ -3938,6 +3938,10 @@ const messages: TranslationMap = {
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'সর্বদা লেবেলগুলি দেখান',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'বন্ধ থাকা অবস্থায়, লেবেলগুলি শুধুমাত্র হোভারে বা সক্রিয় ট্যাবের জন্য প্রদর্শিত হয়৷',
|
||||
'settings.appearance.chatHeading': 'চ্যাট',
|
||||
'settings.appearance.assistantTextMode': 'অ্যাসিস্ট্যান্টের উত্তর টেক্সট হিসেবে',
|
||||
'settings.appearance.assistantTextModeDesc':
|
||||
'আপনার বার্তাগুলি বাবলে রেখে অ্যাসিস্ট্যান্টের উত্তর ফ্রেমহীন টেক্সট হিসেবে দেখান।',
|
||||
'settings.mascot.active': 'সক্রিয়',
|
||||
'settings.mascot.characterDesc': 'চরিত্রের বিবরণ',
|
||||
'settings.mascot.characterHeading': 'চরিত্রের শিরোনাম',
|
||||
|
||||
@@ -4043,6 +4043,10 @@ const messages: TranslationMap = {
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Beschriftungen immer anzeigen',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'Wenn diese Option deaktiviert ist, werden Beschriftungen nur beim Hover oder für die aktive Registerkarte angezeigt.',
|
||||
'settings.appearance.chatHeading': 'Chat',
|
||||
'settings.appearance.assistantTextMode': 'Assistentenantworten als Text',
|
||||
'settings.appearance.assistantTextModeDesc':
|
||||
'Zeigt Assistentenantworten als ungerahmten Text an und lässt deine Nachrichten in Blasen.',
|
||||
'settings.mascot.active': 'Aktiv',
|
||||
'settings.mascot.characterDesc': 'Charakterbeschreibung',
|
||||
'settings.mascot.characterHeading': 'Zeichenüberschrift',
|
||||
|
||||
@@ -4458,6 +4458,10 @@ const en: TranslationMap = {
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Always show labels',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'When off, labels only appear on hover or for the active tab.',
|
||||
'settings.appearance.chatHeading': 'Chat',
|
||||
'settings.appearance.assistantTextMode': 'Plain assistant responses',
|
||||
'settings.appearance.assistantTextModeDesc':
|
||||
'Render assistant replies as unframed text while keeping your messages in bubbles.',
|
||||
'settings.mascot.active': 'Active',
|
||||
'settings.mascot.characterDesc': 'Choose your OpenHuman character.',
|
||||
'settings.mascot.characterHeading': 'Character',
|
||||
|
||||
@@ -4018,6 +4018,10 @@ const messages: TranslationMap = {
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Mostrar siempre etiquetas',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'Cuando está desactivado, las etiquetas solo aparecen al pasar el mouse o para la pestaña activa.',
|
||||
'settings.appearance.chatHeading': 'Chat',
|
||||
'settings.appearance.assistantTextMode': 'Respuestas del asistente en texto',
|
||||
'settings.appearance.assistantTextModeDesc':
|
||||
'Muestra las respuestas del asistente como texto sin marco y mantiene tus mensajes en burbujas.',
|
||||
'settings.mascot.active': 'Activo',
|
||||
'settings.mascot.characterDesc': 'Descripción del personaje',
|
||||
'settings.mascot.characterHeading': 'Encabezado del personaje',
|
||||
|
||||
@@ -4034,6 +4034,10 @@ const messages: TranslationMap = {
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Toujours afficher les étiquettes',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
"Lorsqu'elle est désactivée, les étiquettes n'apparaissent qu'au survol ou pour l'onglet actif.",
|
||||
'settings.appearance.chatHeading': 'Chat',
|
||||
'settings.appearance.assistantTextMode': "Réponses de l'assistant en texte",
|
||||
'settings.appearance.assistantTextModeDesc':
|
||||
"Affiche les réponses de l'assistant en texte sans cadre tout en gardant vos messages en bulles.",
|
||||
'settings.mascot.active': 'Actif',
|
||||
'settings.mascot.characterDesc': 'Description du personnage',
|
||||
'settings.mascot.characterHeading': 'Titre du personnage',
|
||||
|
||||
@@ -3946,6 +3946,10 @@ const messages: TranslationMap = {
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'हमेशा लेबल दिखाएं',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'बंद होने पर, लेबल केवल होवर पर या सक्रिय टैब के लिए दिखाई देते हैं।',
|
||||
'settings.appearance.chatHeading': 'चैट',
|
||||
'settings.appearance.assistantTextMode': 'असिस्टेंट जवाब टेक्स्ट में',
|
||||
'settings.appearance.assistantTextModeDesc':
|
||||
'असिस्टेंट के जवाबों को बिना फ्रेम वाले टेक्स्ट के रूप में दिखाएं और आपके संदेश बबल में रखें।',
|
||||
'settings.mascot.active': 'एक्टिव',
|
||||
'settings.mascot.characterDesc': 'कैरेक्टर विवरण',
|
||||
'settings.mascot.characterHeading': 'कैरेक्टर शीर्षक',
|
||||
|
||||
@@ -3954,6 +3954,10 @@ const messages: TranslationMap = {
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Selalu tampilkan label',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'Saat nonaktif, label hanya muncul saat diarahkan atau untuk tab yang aktif.',
|
||||
'settings.appearance.chatHeading': 'Chat',
|
||||
'settings.appearance.assistantTextMode': 'Respons asisten sebagai teks',
|
||||
'settings.appearance.assistantTextModeDesc':
|
||||
'Tampilkan balasan asisten sebagai teks tanpa bingkai, sementara pesan Anda tetap dalam gelembung.',
|
||||
'settings.mascot.active': 'Aktif',
|
||||
'settings.mascot.characterDesc': 'Deskripsi karakter',
|
||||
'settings.mascot.characterHeading': 'Judul karakter',
|
||||
|
||||
@@ -4010,6 +4010,10 @@ const messages: TranslationMap = {
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Mostra sempre le etichette',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'Quando disattivata, le etichette vengono visualizzate solo al passaggio del mouse o per la scheda attiva.',
|
||||
'settings.appearance.chatHeading': 'Chat',
|
||||
'settings.appearance.assistantTextMode': "Risposte dell'assistente in testo",
|
||||
'settings.appearance.assistantTextModeDesc':
|
||||
"Mostra le risposte dell'assistente come testo senza cornice mantenendo i tuoi messaggi nei fumetti.",
|
||||
'settings.mascot.active': 'Attivo',
|
||||
'settings.mascot.characterDesc': 'Descrizione personaggio',
|
||||
'settings.mascot.characterHeading': 'Intestazione personaggio',
|
||||
|
||||
@@ -3896,6 +3896,10 @@ const messages: TranslationMap = {
|
||||
'settings.appearance.tabBarAlwaysShowLabels': '항상 레이블 표시',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'끄면 레이블은 마우스를 가져가거나 활성 탭에 대해서만 표시됩니다.',
|
||||
'settings.appearance.chatHeading': '채팅',
|
||||
'settings.appearance.assistantTextMode': '어시스턴트 답변을 텍스트로 표시',
|
||||
'settings.appearance.assistantTextModeDesc':
|
||||
'사용자 메시지는 말풍선으로 유지하고 어시스턴트 답변은 프레임 없는 텍스트로 표시합니다.',
|
||||
'settings.mascot.active': '활성',
|
||||
'settings.mascot.characterDesc': '캐릭터 설명',
|
||||
'settings.mascot.characterHeading': '캐릭터 제목',
|
||||
|
||||
@@ -4007,6 +4007,10 @@ const messages: TranslationMap = {
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Zawsze pokazuj etykiety',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'Wyłączone — etykiety pojawiają się tylko po najechaniu lub dla aktywnej zakładki.',
|
||||
'settings.appearance.chatHeading': 'Czat',
|
||||
'settings.appearance.assistantTextMode': 'Odpowiedzi asystenta jako tekst',
|
||||
'settings.appearance.assistantTextModeDesc':
|
||||
'Wyświetla odpowiedzi asystenta jako tekst bez ramki, a Twoje wiadomości pozostawia w dymkach.',
|
||||
'settings.mascot.active': 'Aktywny',
|
||||
'settings.mascot.characterDesc': 'Wybierz charakter maskotki OpenHuman.',
|
||||
'settings.mascot.characterHeading': 'Charakter',
|
||||
|
||||
@@ -4011,6 +4011,10 @@ const messages: TranslationMap = {
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Sempre mostrar rótulos',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'Quando desativado, os rótulos só aparecem ao passar o mouse ou para a guia ativa.',
|
||||
'settings.appearance.chatHeading': 'Chat',
|
||||
'settings.appearance.assistantTextMode': 'Respostas do assistente em texto',
|
||||
'settings.appearance.assistantTextModeDesc':
|
||||
'Renderiza as respostas do assistente como texto sem moldura e mantém suas mensagens em balões.',
|
||||
'settings.mascot.active': 'Ativo',
|
||||
'settings.mascot.characterDesc': 'Descrição do personagem',
|
||||
'settings.mascot.characterHeading': 'Título do personagem',
|
||||
|
||||
@@ -3975,6 +3975,10 @@ const messages: TranslationMap = {
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Всегда показывать метки',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'Если этот параметр отключен, метки отображаются только при наведении курсора мыши или на активной вкладке.',
|
||||
'settings.appearance.chatHeading': 'Чат',
|
||||
'settings.appearance.assistantTextMode': 'Ответы ассистента текстом',
|
||||
'settings.appearance.assistantTextModeDesc':
|
||||
'Показывает ответы ассистента как текст без рамки, оставляя ваши сообщения в пузырьках.',
|
||||
'settings.mascot.active': 'Активно',
|
||||
'settings.mascot.characterDesc': 'Описание персонажа',
|
||||
'settings.mascot.characterHeading': 'Персонаж',
|
||||
|
||||
@@ -3745,6 +3745,9 @@ const messages: TranslationMap = {
|
||||
'settings.appearance.tabBarHeading': '底部标签栏',
|
||||
'settings.appearance.tabBarAlwaysShowLabels': '始终显示标签',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc': '关闭时,标签仅出现在悬停时或活动选项卡上。',
|
||||
'settings.appearance.chatHeading': '聊天',
|
||||
'settings.appearance.assistantTextMode': '助手回复以文本显示',
|
||||
'settings.appearance.assistantTextModeDesc': '将助手回复渲染为无边框文本,同时保留你的消息气泡。',
|
||||
'settings.mascot.active': '活跃',
|
||||
'settings.mascot.characterDesc': '选择你的 OpenHuman 角色',
|
||||
'settings.mascot.characterHeading': '角色',
|
||||
|
||||
+224
-201
@@ -78,7 +78,11 @@ import {
|
||||
openhumanVoiceTts,
|
||||
} from '../utils/tauriCommands';
|
||||
import { formatTimelineEntry } from '../utils/toolTimelineFormatting';
|
||||
import { AgentMessageBubble, BubbleMarkdown } from './conversations/components/AgentMessageBubble';
|
||||
import {
|
||||
AgentMessageBubble,
|
||||
AgentMessageText,
|
||||
BubbleMarkdown,
|
||||
} from './conversations/components/AgentMessageBubble';
|
||||
import { CitationChips, type MessageCitation } from './conversations/components/CitationChips';
|
||||
import { SubagentDrawer } from './conversations/components/SubagentDrawer';
|
||||
import { TaskKanbanBoard } from './conversations/components/TaskKanbanBoard';
|
||||
@@ -238,6 +242,9 @@ const Conversations = ({
|
||||
const streamingAssistantByThread = useAppSelector(
|
||||
state => state.chatRuntime.streamingAssistantByThread
|
||||
);
|
||||
const agentMessageViewMode = useAppSelector(
|
||||
state => state.theme?.agentMessageViewMode ?? 'bubbles'
|
||||
);
|
||||
const inferenceTurnLifecycleByThread = useAppSelector(
|
||||
state => state.chatRuntime.inferenceTurnLifecycleByThread
|
||||
);
|
||||
@@ -1627,214 +1634,230 @@ const Conversations = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{visibleMessages.map(msg => (
|
||||
<div key={msg.id}>
|
||||
{shouldRenderTimelineBeforeLatestAgentMessage &&
|
||||
latestVisibleAgentMessage?.id === msg.id && (
|
||||
<ToolTimelineBlock
|
||||
entries={selectedThreadToolTimeline}
|
||||
onViewSubagent={sub => setOpenSubagentTaskId(sub.taskId)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`group/msg flex ${msg.sender === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className="relative w-fit max-w-[75%]">
|
||||
{msg.sender === 'agent' ? (
|
||||
<div className="space-y-1">
|
||||
{splitAgentMessageIntoBubbles(msg.content).map(
|
||||
(segment, index, parts) => {
|
||||
const position: AgentBubblePosition =
|
||||
parts.length === 1
|
||||
? 'single'
|
||||
: index === 0
|
||||
? 'first'
|
||||
: index === parts.length - 1
|
||||
? 'last'
|
||||
: 'middle';
|
||||
|
||||
return (
|
||||
<AgentMessageBubble
|
||||
key={`${msg.id}:${index}`}
|
||||
content={segment}
|
||||
position={position}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)}
|
||||
{(() => {
|
||||
const raw = msg.extraMetadata?.citations;
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const citations = raw.filter(
|
||||
(item): item is MessageCitation =>
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
typeof (item as MessageCitation).id === 'string' &&
|
||||
typeof (item as MessageCitation).key === 'string' &&
|
||||
typeof (item as MessageCitation).snippet === 'string' &&
|
||||
typeof (item as MessageCitation).timestamp === 'string'
|
||||
);
|
||||
if (citations.length === 0) return null;
|
||||
return <CitationChips citations={citations} />;
|
||||
})()}
|
||||
{latestVisibleMessage?.id === msg.id && (
|
||||
<p className="px-1 text-[10px] text-stone-400 dark:text-neutral-500">
|
||||
{formatRelativeTime(msg.createdAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{(() => {
|
||||
const dataUris = Array.isArray(msg.extraMetadata?.attachmentDataUris)
|
||||
? (msg.extraMetadata.attachmentDataUris as string[])
|
||||
: parseMessageImages(msg.content ?? '').dataUris;
|
||||
const hasImages = dataUris.length > 0;
|
||||
const showTime = latestVisibleMessage?.id === msg.id;
|
||||
return (
|
||||
<>
|
||||
{hasImages && (
|
||||
<div className="flex flex-wrap gap-1.5 justify-end">
|
||||
{dataUris.map((uri, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={uri}
|
||||
alt=""
|
||||
className="max-w-[200px] max-h-[200px] rounded-2xl object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(msg.content || showTime) && (
|
||||
<div className="rounded-2xl px-4 py-2.5 bg-primary-500 text-white rounded-br-md break-words overflow-hidden">
|
||||
{msg.content && (
|
||||
<BubbleMarkdown content={msg.content} tone="user" />
|
||||
)}
|
||||
{showTime && (
|
||||
<p
|
||||
className={`${msg.content ? 'mt-1' : ''} text-[10px] text-white/60`}>
|
||||
{formatRelativeTime(msg.createdAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{visibleMessages.map(msg => {
|
||||
const isAgentTextMode = msg.sender === 'agent' && agentMessageViewMode === 'text';
|
||||
return (
|
||||
<div key={msg.id}>
|
||||
{shouldRenderTimelineBeforeLatestAgentMessage &&
|
||||
latestVisibleAgentMessage?.id === msg.id && (
|
||||
<ToolTimelineBlock
|
||||
entries={selectedThreadToolTimeline}
|
||||
onViewSubagent={sub => setOpenSubagentTaskId(sub.taskId)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-id="chat-message-copy"
|
||||
onClick={() => handleCopyMessage(msg.id, msg.content)}
|
||||
className={`absolute -top-1 ${msg.sender === 'user' ? '-left-8' : '-right-8'} p-1 rounded-md opacity-0 group-hover/msg:opacity-100 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 transition-all`}
|
||||
title={t('chat.copyResponse')}>
|
||||
{copiedMessageId === msg.id ? (
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-sage-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{(() => {
|
||||
if (latestVisibleMessage?.id !== msg.id) return null;
|
||||
const myReactions =
|
||||
(msg.extraMetadata?.myReactions as string[] | undefined) ?? [];
|
||||
const hasReactions = myReactions.length > 0;
|
||||
// Show reaction row only for the most recent visible message.
|
||||
if (!hasReactions && msg.sender !== 'agent') return null;
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-1 flex-wrap min-h-[20px]">
|
||||
{myReactions.map(emoji => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
data-analytics-id="chat-message-reaction-remove"
|
||||
onClick={() =>
|
||||
selectedThreadId &&
|
||||
void dispatch(
|
||||
persistReaction({
|
||||
threadId: selectedThreadId,
|
||||
messageId: msg.id,
|
||||
emoji,
|
||||
})
|
||||
)
|
||||
<div
|
||||
className={`group/msg flex ${msg.sender === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`relative ${
|
||||
isAgentTextMode ? 'w-full max-w-full' : 'w-fit max-w-[75%]'
|
||||
}`}>
|
||||
{msg.sender === 'agent' ? (
|
||||
<div className="space-y-1">
|
||||
{agentMessageViewMode === 'text' ? (
|
||||
<AgentMessageText content={msg.content} />
|
||||
) : (
|
||||
splitAgentMessageIntoBubbles(msg.content).map(
|
||||
(segment, index, parts) => {
|
||||
const position: AgentBubblePosition =
|
||||
parts.length === 1
|
||||
? 'single'
|
||||
: index === 0
|
||||
? 'first'
|
||||
: index === parts.length - 1
|
||||
? 'last'
|
||||
: 'middle';
|
||||
|
||||
return (
|
||||
<AgentMessageBubble
|
||||
key={`${msg.id}:${index}`}
|
||||
content={segment}
|
||||
position={position}
|
||||
/>
|
||||
);
|
||||
}
|
||||
className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-primary-100 border border-primary-200 text-xs transition-colors hover:bg-primary-200"
|
||||
title={t('chat.removeReaction').replace('{emoji}', emoji)}>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
{msg.sender === 'agent' &&
|
||||
(reactionPickerMsgId === msg.id ? (
|
||||
<div className="flex items-center gap-0.5 px-1 py-0.5 rounded-full bg-stone-100 dark:bg-neutral-800">
|
||||
{['👍', '❤️', '😂', '🔥', '👀', '🎯'].map(emoji => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
data-analytics-id="chat-message-reaction-pick"
|
||||
onClick={() => {
|
||||
if (selectedThreadId) {
|
||||
void dispatch(
|
||||
persistReaction({
|
||||
threadId: selectedThreadId,
|
||||
messageId: msg.id,
|
||||
emoji,
|
||||
})
|
||||
);
|
||||
}
|
||||
setReactionPickerMsgId(null);
|
||||
}}
|
||||
className="px-0.5 rounded text-sm hover:scale-125 transition-transform"
|
||||
title={emoji}>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-id="chat-message-reaction-close"
|
||||
onClick={() => setReactionPickerMsgId(null)}
|
||||
className="ml-0.5 text-stone-600 dark:text-neutral-300 hover:text-stone-400 dark:hover:text-neutral-500 text-xs px-0.5">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
)
|
||||
)}
|
||||
{(() => {
|
||||
const raw = msg.extraMetadata?.citations;
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const citations = raw.filter(
|
||||
(item): item is MessageCitation =>
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
typeof (item as MessageCitation).id === 'string' &&
|
||||
typeof (item as MessageCitation).key === 'string' &&
|
||||
typeof (item as MessageCitation).snippet === 'string' &&
|
||||
typeof (item as MessageCitation).timestamp === 'string'
|
||||
);
|
||||
if (citations.length === 0) return null;
|
||||
return <CitationChips citations={citations} />;
|
||||
})()}
|
||||
{latestVisibleMessage?.id === msg.id && (
|
||||
<p className="px-1 text-[10px] text-stone-400 dark:text-neutral-500">
|
||||
{formatRelativeTime(msg.createdAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{(() => {
|
||||
const dataUris = Array.isArray(msg.extraMetadata?.attachmentDataUris)
|
||||
? (msg.extraMetadata.attachmentDataUris as string[])
|
||||
: parseMessageImages(msg.content ?? '').dataUris;
|
||||
const hasImages = dataUris.length > 0;
|
||||
const showTime = latestVisibleMessage?.id === msg.id;
|
||||
return (
|
||||
<>
|
||||
{hasImages && (
|
||||
<div className="flex flex-wrap gap-1.5 justify-end">
|
||||
{dataUris.map((uri, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={uri}
|
||||
alt=""
|
||||
className="max-w-[200px] max-h-[200px] rounded-2xl object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(msg.content || showTime) && (
|
||||
<div className="rounded-2xl px-4 py-2.5 bg-primary-500 text-white rounded-br-md break-words overflow-hidden">
|
||||
{msg.content && (
|
||||
<BubbleMarkdown content={msg.content} tone="user" />
|
||||
)}
|
||||
{showTime && (
|
||||
<p
|
||||
className={`${msg.content ? 'mt-1' : ''} text-[10px] text-white/60`}>
|
||||
{formatRelativeTime(msg.createdAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-id="chat-message-copy"
|
||||
onClick={() => handleCopyMessage(msg.id, msg.content)}
|
||||
className={`absolute -top-1 ${
|
||||
isAgentTextMode
|
||||
? 'right-0'
|
||||
: msg.sender === 'user'
|
||||
? '-left-8'
|
||||
: '-right-8'
|
||||
} p-1 rounded-md opacity-0 group-hover/msg:opacity-100 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 transition-all`}
|
||||
title={t('chat.copyResponse')}>
|
||||
{copiedMessageId === msg.id ? (
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-sage-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{(() => {
|
||||
if (latestVisibleMessage?.id !== msg.id) return null;
|
||||
const myReactions =
|
||||
(msg.extraMetadata?.myReactions as string[] | undefined) ?? [];
|
||||
const hasReactions = myReactions.length > 0;
|
||||
// Show reaction row only for the most recent visible message.
|
||||
if (!hasReactions && msg.sender !== 'agent') return null;
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-1 flex-wrap min-h-[20px]">
|
||||
{myReactions.map(emoji => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
data-analytics-id="chat-message-reaction-open"
|
||||
onClick={() => setReactionPickerMsgId(msg.id)}
|
||||
className="opacity-0 group-hover/msg:opacity-100 flex items-center px-1.5 py-0.5 rounded-full bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-200 dark:bg-neutral-800 dark:hover:bg-neutral-800 text-stone-500 dark:text-neutral-400 hover:text-stone-300 dark:hover:text-neutral-600 text-xs transition-all"
|
||||
title={t('chat.addReaction')}>
|
||||
+
|
||||
data-analytics-id="chat-message-reaction-remove"
|
||||
onClick={() =>
|
||||
selectedThreadId &&
|
||||
void dispatch(
|
||||
persistReaction({
|
||||
threadId: selectedThreadId,
|
||||
messageId: msg.id,
|
||||
emoji,
|
||||
})
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-primary-100 border border-primary-200 text-xs transition-colors hover:bg-primary-200"
|
||||
title={t('chat.removeReaction').replace('{emoji}', emoji)}>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{msg.sender === 'agent' &&
|
||||
(reactionPickerMsgId === msg.id ? (
|
||||
<div className="flex items-center gap-0.5 px-1 py-0.5 rounded-full bg-stone-100 dark:bg-neutral-800">
|
||||
{['👍', '❤️', '😂', '🔥', '👀', '🎯'].map(emoji => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
data-analytics-id="chat-message-reaction-pick"
|
||||
onClick={() => {
|
||||
if (selectedThreadId) {
|
||||
void dispatch(
|
||||
persistReaction({
|
||||
threadId: selectedThreadId,
|
||||
messageId: msg.id,
|
||||
emoji,
|
||||
})
|
||||
);
|
||||
}
|
||||
setReactionPickerMsgId(null);
|
||||
}}
|
||||
className="px-0.5 rounded text-sm hover:scale-125 transition-transform"
|
||||
title={emoji}>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-id="chat-message-reaction-close"
|
||||
onClick={() => setReactionPickerMsgId(null)}
|
||||
className="ml-0.5 text-stone-600 dark:text-neutral-300 hover:text-stone-400 dark:hover:text-neutral-500 text-xs px-0.5">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-id="chat-message-reaction-open"
|
||||
onClick={() => setReactionPickerMsgId(msg.id)}
|
||||
className="opacity-0 group-hover/msg:opacity-100 flex items-center px-1.5 py-0.5 rounded-full bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-200 dark:bg-neutral-800 dark:hover:bg-neutral-800 text-stone-500 dark:text-neutral-400 hover:text-stone-300 dark:hover:text-neutral-600 text-xs transition-all"
|
||||
title={t('chat.addReaction')}>
|
||||
+
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{isSending &&
|
||||
// Suppress the legacy 3-dot placeholder once streaming
|
||||
// output (visible text or thinking) has started — the
|
||||
|
||||
@@ -23,8 +23,9 @@ import chatRuntimeReducer, {
|
||||
setToolTimelineForThread,
|
||||
} from '../../store/chatRuntimeSlice';
|
||||
import socketReducer from '../../store/socketSlice';
|
||||
import themeReducer from '../../store/themeSlice';
|
||||
import threadReducer, { setSelectedThread } from '../../store/threadSlice';
|
||||
import type { Thread } from '../../types/thread';
|
||||
import type { Thread, ThreadMessage } from '../../types/thread';
|
||||
|
||||
// ── Hoisted mock state ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -172,6 +173,7 @@ function buildStore(preload: Record<string, unknown> = {}) {
|
||||
socket: socketReducer,
|
||||
chatRuntime: chatRuntimeReducer,
|
||||
agentProfiles: agentProfileReducer,
|
||||
theme: themeReducer,
|
||||
}),
|
||||
preloadedState: preload as never,
|
||||
});
|
||||
@@ -294,6 +296,7 @@ async function submitComposerText(textarea: HTMLElement, text: string) {
|
||||
describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
// Reset the mock to defaults for each test
|
||||
mockGetThreads.mockResolvedValue({ threads: [], count: 0 });
|
||||
mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
|
||||
@@ -405,6 +408,142 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders assistant messages as unframed text when the appearance preference is enabled', async () => {
|
||||
const thread = makeThread({ id: 'view-mode-thread', title: 'View Mode Thread' });
|
||||
const messages: ThreadMessage[] = [
|
||||
{
|
||||
id: 'm-user',
|
||||
sender: 'user',
|
||||
type: 'text',
|
||||
content: 'Can you summarize this?',
|
||||
extraMetadata: {},
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'm-agent',
|
||||
sender: 'agent',
|
||||
type: 'text',
|
||||
content: 'Long agent output\n\nwith enough structure to prefer a text view.',
|
||||
extraMetadata: {},
|
||||
createdAt: '2026-01-01T00:01:00.000Z',
|
||||
},
|
||||
];
|
||||
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
|
||||
mockGetThreadMessages.mockResolvedValue({ messages, count: messages.length });
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({
|
||||
thread: {
|
||||
...selectedThreadState(thread),
|
||||
messagesByThreadId: { [thread.id]: messages },
|
||||
messages,
|
||||
},
|
||||
socket: socketState('connected'),
|
||||
theme: {
|
||||
mode: 'system',
|
||||
tabBarLabels: 'hover',
|
||||
fontSize: 'medium',
|
||||
agentMessageViewMode: 'text',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('agent-message-text')).toHaveTextContent(
|
||||
'Long agent output with enough structure to prefer a text view.'
|
||||
);
|
||||
expect(screen.getByText('Can you summarize this?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps bubble mode interactions for assistant citations, copy, and reactions', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
|
||||
const thread = makeThread({ id: 'bubble-mode-thread', title: 'Bubble Mode Thread' });
|
||||
const agentContent =
|
||||
'First assistant paragraph with enough text to render.\n\nSecond assistant paragraph stays in bubbles.';
|
||||
const messages: ThreadMessage[] = [
|
||||
{
|
||||
id: 'm-agent-bubble',
|
||||
sender: 'agent',
|
||||
type: 'text',
|
||||
content: agentContent,
|
||||
extraMetadata: {
|
||||
citations: [
|
||||
{
|
||||
id: 'cite-1',
|
||||
key: 'memory-key',
|
||||
namespace: 'personal',
|
||||
snippet: 'Remembered preference',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
score: 0.91,
|
||||
},
|
||||
],
|
||||
myReactions: ['👍'],
|
||||
},
|
||||
createdAt: '2026-01-01T00:01:00.000Z',
|
||||
},
|
||||
];
|
||||
vi.mocked(threadApi.updateMessage).mockImplementation(
|
||||
async (_threadId, _messageId, extraMetadata) =>
|
||||
({ ...messages[0], extraMetadata }) as ThreadMessage
|
||||
);
|
||||
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
|
||||
mockGetThreadMessages.mockResolvedValue({ messages, count: messages.length });
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({
|
||||
thread: {
|
||||
...selectedThreadState(thread),
|
||||
messagesByThreadId: { [thread.id]: messages },
|
||||
messages,
|
||||
},
|
||||
socket: socketState('connected'),
|
||||
theme: {
|
||||
mode: 'system',
|
||||
tabBarLabels: 'hover',
|
||||
fontSize: 'medium',
|
||||
agentMessageViewMode: 'bubbles',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('agent-message-text')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('First assistant paragraph with enough text to render.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('personal 91%')).toBeInTheDocument();
|
||||
expect(screen.getByTitle(/Remembered preference/)).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTitle('Copy response'));
|
||||
});
|
||||
expect(writeText).toHaveBeenCalledWith(agentContent);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTitle('Remove 👍'));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(threadApi.updateMessage).toHaveBeenCalledWith(
|
||||
thread.id,
|
||||
'm-agent-bubble',
|
||||
expect.objectContaining({ myReactions: [] })
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTitle('Add reaction'));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTitle('🎯'));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(threadApi.updateMessage).toHaveBeenCalledWith(
|
||||
thread.id,
|
||||
'm-agent-bubble',
|
||||
expect.objectContaining({ myReactions: expect.arrayContaining(['🎯']) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Covers lines 1455-1483: quota pill loading state
|
||||
it('renders "Loading…" quota pill when isLoadingBudget=true', async () => {
|
||||
mockUseUsageState.mockReturnValue({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { BubbleMarkdown, TableCellMarkdown } from './AgentMessageBubble';
|
||||
import { AgentMessageText, BubbleMarkdown, TableCellMarkdown } from './AgentMessageBubble';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ openUrl: vi.fn(), openWorkspacePath: vi.fn() }));
|
||||
|
||||
@@ -116,3 +116,25 @@ describe('BubbleMarkdown math rendering', () => {
|
||||
expect(container.textContent).toContain('$20');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentMessageText', () => {
|
||||
test('renders openhuman link pills without assistant bubble chrome', () => {
|
||||
render(
|
||||
<AgentMessageText
|
||||
content={'<openhuman-link path="settings/appearance">Appearance</openhuman-link>'}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('agent-message-text')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Appearance/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('uses the dedicated table renderer in plain text mode', () => {
|
||||
render(<AgentMessageText content={'| Name | Value |\n| --- | --- |\n| OpenHuman | 42 |'} />);
|
||||
|
||||
expect(screen.getByTestId('agent-message-text')).toBeInTheDocument();
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
expect(screen.getByRole('columnheader', { name: 'Name' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: 'OpenHuman' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
const MATH_REMARK_PLUGINS = [remarkMath];
|
||||
const MATH_REHYPE_PLUGINS = [rehypeKatex];
|
||||
const EMPTY_PLUGINS: [] = [];
|
||||
type ParsedMarkdownTable = NonNullable<ReturnType<typeof parseMarkdownTable>>;
|
||||
|
||||
/**
|
||||
* Pill rendered below an agent bubble for each
|
||||
@@ -122,6 +123,49 @@ export function TableCellMarkdown({ content }: { content: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AgentMarkdownTable({
|
||||
table,
|
||||
className,
|
||||
}: {
|
||||
table: ParsedMarkdownTable;
|
||||
className: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-max min-w-full border-collapse text-left text-sm text-stone-800 dark:text-neutral-100">
|
||||
<thead className="bg-stone-100 dark:bg-neutral-800/90">
|
||||
<tr>
|
||||
{table.headers.map(header => (
|
||||
<th
|
||||
key={header}
|
||||
className="max-w-[25vw] border-b border-stone-200 dark:border-neutral-800 px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.08em] text-stone-500 dark:text-neutral-400">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.rows.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={`${rowIndex}:${row.join('|')}`}
|
||||
className="odd:bg-white dark:odd:bg-neutral-900 even:bg-stone-50 dark:even:bg-neutral-800/60">
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td
|
||||
key={`${rowIndex}:${cellIndex}:${cell}`}
|
||||
className="max-w-[25vw] border-t border-stone-200 dark:border-neutral-800 px-4 py-3 align-top text-sm text-stone-700 dark:text-neutral-200">
|
||||
<TableCellMarkdown content={cell} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentMessageBubble({
|
||||
content,
|
||||
position = 'single',
|
||||
@@ -144,39 +188,10 @@ export function AgentMessageBubble({
|
||||
|
||||
if (table) {
|
||||
return (
|
||||
<div
|
||||
className={`w-full max-w-full overflow-hidden border border-stone-200 dark:border-neutral-800 bg-white/90 dark:bg-neutral-900/90 shadow-sm ${bubbleChrome}`}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-max min-w-full border-collapse text-left text-sm text-stone-800 dark:text-neutral-100">
|
||||
<thead className="bg-stone-100 dark:bg-neutral-800/90">
|
||||
<tr>
|
||||
{table.headers.map(header => (
|
||||
<th
|
||||
key={header}
|
||||
className="max-w-[25vw] border-b border-stone-200 dark:border-neutral-800 px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.08em] text-stone-500 dark:text-neutral-400">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.rows.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={`${rowIndex}:${row.join('|')}`}
|
||||
className="odd:bg-white dark:odd:bg-neutral-900 even:bg-stone-50 dark:even:bg-neutral-800/60">
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td
|
||||
key={`${rowIndex}:${cellIndex}:${cell}`}
|
||||
className="max-w-[25vw] border-t border-stone-200 dark:border-neutral-800 px-4 py-3 align-top text-sm text-stone-700 dark:text-neutral-200">
|
||||
<TableCellMarkdown content={cell} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<AgentMarkdownTable
|
||||
table={table}
|
||||
className={`w-full max-w-full overflow-hidden border border-stone-200 dark:border-neutral-800 bg-white/90 dark:bg-neutral-900/90 shadow-sm ${bubbleChrome}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,3 +217,39 @@ export function AgentMessageBubble({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentMessageText({ content }: { content: string }) {
|
||||
const segments = parseBubbleSegments(content);
|
||||
const textContent = segments
|
||||
.filter(s => s.kind === 'text')
|
||||
.map(s => s.text)
|
||||
.join('')
|
||||
.trim();
|
||||
const linkSegments = segments.filter(
|
||||
(s): s is Extract<typeof s, { kind: 'link' }> => s.kind === 'link'
|
||||
);
|
||||
const table = parseMarkdownTable(textContent);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full min-w-0 px-1 py-1 text-stone-900 dark:text-neutral-100"
|
||||
data-testid="agent-message-text">
|
||||
{table ? (
|
||||
<AgentMarkdownTable table={table} className="w-full max-w-full overflow-hidden" />
|
||||
) : (
|
||||
textContent && <BubbleMarkdown content={textContent} />
|
||||
)}
|
||||
{linkSegments.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{linkSegments.map((segment, idx) => (
|
||||
<OpenhumanLinkPill
|
||||
key={`pill-${idx}-${segment.path}`}
|
||||
path={segment.path}
|
||||
label={segment.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ const persistedLocaleReducer = persistReducer(localePersistConfig, localeReducer
|
||||
const themePersistConfig = {
|
||||
key: 'theme',
|
||||
storage: localStorageAdapter,
|
||||
whitelist: ['mode', 'tabBarLabels', 'fontSize'],
|
||||
whitelist: ['mode', 'tabBarLabels', 'fontSize', 'agentMessageViewMode'],
|
||||
};
|
||||
const persistedThemeReducer = persistReducer(themePersistConfig, themeReducer);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import themeReducer, {
|
||||
FONT_SIZE_PX,
|
||||
type FontSize,
|
||||
setAgentMessageViewMode,
|
||||
setFontSize,
|
||||
setTabBarLabels,
|
||||
setThemeMode,
|
||||
@@ -14,6 +15,11 @@ describe('themeSlice', () => {
|
||||
expect(state.fontSize).toBe('medium');
|
||||
});
|
||||
|
||||
it('defaults assistant message rendering to bubbles', () => {
|
||||
const state = themeReducer(undefined, { type: '@@INIT' });
|
||||
expect(state.agentMessageViewMode).toBe('bubbles');
|
||||
});
|
||||
|
||||
it('updates fontSize via setFontSize', () => {
|
||||
let state = themeReducer(undefined, { type: '@@INIT' });
|
||||
state = themeReducer(state, setFontSize('large'));
|
||||
@@ -27,7 +33,18 @@ describe('themeSlice', () => {
|
||||
state = themeReducer(state, setThemeMode('dark'));
|
||||
state = themeReducer(state, setTabBarLabels('always'));
|
||||
state = themeReducer(state, setFontSize('xlarge'));
|
||||
expect(state).toEqual({ mode: 'dark', tabBarLabels: 'always', fontSize: 'xlarge' });
|
||||
expect(state).toEqual({
|
||||
mode: 'dark',
|
||||
tabBarLabels: 'always',
|
||||
fontSize: 'xlarge',
|
||||
agentMessageViewMode: 'bubbles',
|
||||
});
|
||||
});
|
||||
|
||||
it('updates assistant message view mode', () => {
|
||||
let state = themeReducer(undefined, { type: '@@INIT' });
|
||||
state = themeReducer(state, setAgentMessageViewMode('text'));
|
||||
expect(state.agentMessageViewMode).toBe('text');
|
||||
});
|
||||
|
||||
it('maps every font size to a concrete px value', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
export type TabBarLabels = 'hover' | 'always';
|
||||
export type AgentMessageViewMode = 'bubbles' | 'text';
|
||||
/**
|
||||
* Global app font size (issue #3120). Drives the root `<html>` font-size, which
|
||||
* scales every rem-based Tailwind text utility — including chat messages and the
|
||||
@@ -26,9 +27,15 @@ interface ThemeState {
|
||||
mode: ThemeMode;
|
||||
tabBarLabels: TabBarLabels;
|
||||
fontSize: FontSize;
|
||||
agentMessageViewMode: AgentMessageViewMode;
|
||||
}
|
||||
|
||||
const initialState: ThemeState = { mode: 'system', tabBarLabels: 'hover', fontSize: 'medium' };
|
||||
const initialState: ThemeState = {
|
||||
mode: 'system',
|
||||
tabBarLabels: 'hover',
|
||||
fontSize: 'medium',
|
||||
agentMessageViewMode: 'bubbles',
|
||||
};
|
||||
|
||||
const themeSlice = createSlice({
|
||||
name: 'theme',
|
||||
@@ -43,10 +50,14 @@ const themeSlice = createSlice({
|
||||
setFontSize(state, action: PayloadAction<FontSize>) {
|
||||
state.fontSize = action.payload;
|
||||
},
|
||||
setAgentMessageViewMode(state, action: PayloadAction<AgentMessageViewMode>) {
|
||||
state.agentMessageViewMode = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setThemeMode, setTabBarLabels, setFontSize } = themeSlice.actions;
|
||||
export const { setThemeMode, setTabBarLabels, setFontSize, setAgentMessageViewMode } =
|
||||
themeSlice.actions;
|
||||
export default themeSlice.reducer;
|
||||
|
||||
/**
|
||||
|
||||
@@ -142,7 +142,7 @@ test.describe('Chat Harness - Subagent', () => {
|
||||
const threadId = await createNewThread(page);
|
||||
await sendMessage(page, PROMPT);
|
||||
|
||||
await expect(page.getByText(CANARY_FINAL)).toBeVisible({ timeout: 45_000 });
|
||||
await expect(page.getByText(CANARY_FINAL)).toBeVisible({ timeout: 75_000 });
|
||||
|
||||
const runtime = await page.evaluate(currentThreadId => {
|
||||
const store = (
|
||||
|
||||
@@ -211,7 +211,7 @@ test.describe('Harness - Composio tool-call prompt flow', () => {
|
||||
|
||||
await sendMessage(page, 'list my GitHub repos');
|
||||
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
|
||||
await expect(page.getByText(/openhuman/i)).toBeVisible();
|
||||
await expect(page.getByText(/openhuman/i).first()).toBeVisible();
|
||||
|
||||
const log = await requests();
|
||||
const llmHits = log.filter(
|
||||
|
||||
@@ -13,12 +13,16 @@ async function openSettings(page: Page, userId: string, hash: string): Promise<v
|
||||
await dismissWalkthroughIfPresent(page);
|
||||
}
|
||||
|
||||
async function themeState(page: Page): Promise<{ mode?: string; tabBarLabels?: string }> {
|
||||
async function themeState(
|
||||
page: Page
|
||||
): Promise<{ mode?: string; tabBarLabels?: string; agentMessageViewMode?: string }> {
|
||||
return page.evaluate(() => {
|
||||
const store = (
|
||||
window as unknown as {
|
||||
__OPENHUMAN_STORE__?: {
|
||||
getState?: () => { theme?: { mode?: string; tabBarLabels?: string } };
|
||||
getState?: () => {
|
||||
theme?: { mode?: string; tabBarLabels?: string; agentMessageViewMode?: string };
|
||||
};
|
||||
};
|
||||
}
|
||||
).__OPENHUMAN_STORE__;
|
||||
@@ -26,7 +30,9 @@ async function themeState(page: Page): Promise<{ mode?: string; tabBarLabels?: s
|
||||
});
|
||||
}
|
||||
|
||||
async function persistedThemeState(page: Page): Promise<{ mode?: string; tabBarLabels?: string }> {
|
||||
async function persistedThemeState(
|
||||
page: Page
|
||||
): Promise<{ mode?: string; tabBarLabels?: string; agentMessageViewMode?: string }> {
|
||||
return page.evaluate(() => {
|
||||
const raw = localStorage.getItem('persist:theme');
|
||||
if (!raw) return {};
|
||||
@@ -35,6 +41,9 @@ async function persistedThemeState(page: Page): Promise<{ mode?: string; tabBarL
|
||||
return {
|
||||
mode: parsed.mode ? JSON.parse(parsed.mode) : undefined,
|
||||
tabBarLabels: parsed.tabBarLabels ? JSON.parse(parsed.tabBarLabels) : undefined,
|
||||
agentMessageViewMode: parsed.agentMessageViewMode
|
||||
? JSON.parse(parsed.agentMessageViewMode)
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
@@ -50,7 +59,7 @@ function unwrap<T>(value: T | { result: T }): T {
|
||||
}
|
||||
|
||||
test.describe('Settings leaf workflows', () => {
|
||||
test('appearance theme mode and tab bar label preference persist in app state', async ({
|
||||
test('appearance theme, tab bar, and chat rendering preferences persist in app state', async ({
|
||||
page,
|
||||
}) => {
|
||||
await openSettings(page, 'pw-settings-appearance', '/settings/appearance');
|
||||
@@ -61,19 +70,23 @@ test.describe('Settings leaf workflows', () => {
|
||||
if ((await labelSwitch.getAttribute('aria-checked')) !== 'true') {
|
||||
await labelSwitch.click();
|
||||
}
|
||||
const assistantTextSwitch = page.getByRole('switch', { name: /Plain assistant responses/ });
|
||||
if ((await assistantTextSwitch.getAttribute('aria-checked')) !== 'true') {
|
||||
await assistantTextSwitch.click();
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(() => themeState(page))
|
||||
.toMatchObject({ mode: 'dark', tabBarLabels: 'always' });
|
||||
.toMatchObject({ mode: 'dark', tabBarLabels: 'always', agentMessageViewMode: 'text' });
|
||||
await expect
|
||||
.poll(() => persistedThemeState(page))
|
||||
.toMatchObject({ mode: 'dark', tabBarLabels: 'always' });
|
||||
.toMatchObject({ mode: 'dark', tabBarLabels: 'always', agentMessageViewMode: 'text' });
|
||||
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
await expect
|
||||
.poll(() => themeState(page))
|
||||
.toMatchObject({ mode: 'dark', tabBarLabels: 'always' });
|
||||
.toMatchObject({ mode: 'dark', tabBarLabels: 'always', agentMessageViewMode: 'text' });
|
||||
});
|
||||
|
||||
test('embeddings custom endpoint setup writes provider, model, and dimensions', async ({
|
||||
|
||||
Reference in New Issue
Block a user