diff --git a/app/src/components/settings/panels/AppearancePanel.test.tsx b/app/src/components/settings/panels/AppearancePanel.test.tsx index 16de804c5..d8eb80763 100644 --- a/app/src/components/settings/panels/AppearancePanel.test.tsx +++ b/app/src/components/settings/panels/AppearancePanel.test.tsx @@ -17,7 +17,9 @@ vi.mock('../components/SettingsHeader', () => ({ function renderPanel(fontSize: 'small' | 'medium' | 'large' | 'xlarge' = 'medium') { return renderWithProviders(, { - preloadedState: { theme: { mode: 'system', tabBarLabels: 'hover', fontSize } }, + preloadedState: { + theme: { mode: 'system', tabBarLabels: 'hover', fontSize, agentMessageViewMode: 'bubbles' }, + }, }); } @@ -45,4 +47,14 @@ describe(' 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'); + }); }); diff --git a/app/src/components/settings/panels/AppearancePanel.tsx b/app/src/components/settings/panels/AppearancePanel.tsx index ba01337ea..ae8501c37 100644 --- a/app/src/components/settings/panels/AppearancePanel.tsx +++ b/app/src/components/settings/panels/AppearancePanel.tsx @@ -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 = () => { + +
+

+ {t('settings.appearance.chatHeading')} +

+
+ +
+
); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 2ead250de..a330e8c43 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': 'عنوان الشخصية', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index ef5ff2665..a399631bd 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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': 'চরিত্রের শিরোনাম', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index fd5c5815e..f4b1811a5 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index fa54ead06..099b56385 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 23f77ff9d..86a145dd6 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 7111c82c1..c111b2b21 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index a014e4d1e..50a64de41 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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': 'कैरेक्टर शीर्षक', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index a30d0a395..55b9ef3b2 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index d46556664..bf0e5320a 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 1752321e5..99f6e8f71 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': '캐릭터 제목', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 2b1a09edf..a264075bf 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 305fff22f..5a804c040 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 94d8d5a0e..4445e3983 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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': 'Персонаж', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 475b80768..38c9472cd 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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': '角色', diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index fc741fcef..a0c88dcc4 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -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 => ( -
- {shouldRenderTimelineBeforeLatestAgentMessage && - latestVisibleAgentMessage?.id === msg.id && ( - setOpenSubagentTaskId(sub.taskId)} - /> - )} -
-
- {msg.sender === 'agent' ? ( -
- {splitAgentMessageIntoBubbles(msg.content).map( - (segment, index, parts) => { - const position: AgentBubblePosition = - parts.length === 1 - ? 'single' - : index === 0 - ? 'first' - : index === parts.length - 1 - ? 'last' - : 'middle'; - - return ( - - ); - } - )} - {(() => { - 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 ; - })()} - {latestVisibleMessage?.id === msg.id && ( -

- {formatRelativeTime(msg.createdAt)} -

- )} -
- ) : ( -
- {(() => { - 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 && ( -
- {dataUris.map((uri, i) => ( - - ))} -
- )} - {(msg.content || showTime) && ( -
- {msg.content && ( - - )} - {showTime && ( -

- {formatRelativeTime(msg.createdAt)} -

- )} -
- )} - - ); - })()} -
+ {visibleMessages.map(msg => { + const isAgentTextMode = msg.sender === 'agent' && agentMessageViewMode === 'text'; + return ( +
+ {shouldRenderTimelineBeforeLatestAgentMessage && + latestVisibleAgentMessage?.id === msg.id && ( + setOpenSubagentTaskId(sub.taskId)} + /> )} - - {(() => { - 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 ( -
- {myReactions.map(emoji => ( - - ))} - {msg.sender === 'agent' && - (reactionPickerMsgId === msg.id ? ( -
- {['👍', '❤️', '😂', '🔥', '👀', '🎯'].map(emoji => ( - - ))} - -
- ) : ( + ) + )} + {(() => { + 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 ; + })()} + {latestVisibleMessage?.id === msg.id && ( +

+ {formatRelativeTime(msg.createdAt)} +

+ )} +
+ ) : ( +
+ {(() => { + 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 && ( +
+ {dataUris.map((uri, i) => ( + + ))} +
+ )} + {(msg.content || showTime) && ( +
+ {msg.content && ( + + )} + {showTime && ( +

+ {formatRelativeTime(msg.createdAt)} +

+ )} +
+ )} + + ); + })()} +
+ )} + + {(() => { + 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 ( +
+ {myReactions.map(emoji => ( ))} -
- ); - })()} + {msg.sender === 'agent' && + (reactionPickerMsgId === msg.id ? ( +
+ {['👍', '❤️', '😂', '🔥', '👀', '🎯'].map(emoji => ( + + ))} + +
+ ) : ( + + ))} +
+ ); + })()} +
- - ))} + ); + })} {isSending && // Suppress the legacy 3-dot placeholder once streaming // output (visible text or thinking) has started — the diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index 52324cf84..cdba358cd 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -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 = {}) { 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({ diff --git a/app/src/pages/conversations/components/AgentMessageBubble.test.tsx b/app/src/pages/conversations/components/AgentMessageBubble.test.tsx index 3496251be..c3f7031c3 100644 --- a/app/src/pages/conversations/components/AgentMessageBubble.test.tsx +++ b/app/src/pages/conversations/components/AgentMessageBubble.test.tsx @@ -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( + Appearance'} + /> + ); + + 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(); + + 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(); + }); +}); diff --git a/app/src/pages/conversations/components/AgentMessageBubble.tsx b/app/src/pages/conversations/components/AgentMessageBubble.tsx index 4074bca60..ca1a4a413 100644 --- a/app/src/pages/conversations/components/AgentMessageBubble.tsx +++ b/app/src/pages/conversations/components/AgentMessageBubble.tsx @@ -19,6 +19,7 @@ import { const MATH_REMARK_PLUGINS = [remarkMath]; const MATH_REHYPE_PLUGINS = [rehypeKatex]; const EMPTY_PLUGINS: [] = []; +type ParsedMarkdownTable = NonNullable>; /** * 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 ( +
+
+ + + + {table.headers.map(header => ( + + ))} + + + + {table.rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
+ {header} +
+ +
+
+
+ ); +} + export function AgentMessageBubble({ content, position = 'single', @@ -144,39 +188,10 @@ export function AgentMessageBubble({ if (table) { return ( -
-
- - - - {table.headers.map(header => ( - - ))} - - - - {table.rows.map((row, rowIndex) => ( - - {row.map((cell, cellIndex) => ( - - ))} - - ))} - -
- {header} -
- -
-
-
+ ); } @@ -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 => s.kind === 'link' + ); + const table = parseMarkdownTable(textContent); + + return ( +
+ {table ? ( + + ) : ( + textContent && + )} + {linkSegments.length > 0 && ( +
+ {linkSegments.map((segment, idx) => ( + + ))} +
+ )} +
+ ); +} diff --git a/app/src/store/index.ts b/app/src/store/index.ts index 7df2d9d7b..8e480f392 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -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); diff --git a/app/src/store/themeSlice.test.ts b/app/src/store/themeSlice.test.ts index 1f2a3af6c..cf4e310a9 100644 --- a/app/src/store/themeSlice.test.ts +++ b/app/src/store/themeSlice.test.ts @@ -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', () => { diff --git a/app/src/store/themeSlice.ts b/app/src/store/themeSlice.ts index 2d843684f..3c40d07ca 100644 --- a/app/src/store/themeSlice.ts +++ b/app/src/store/themeSlice.ts @@ -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 `` 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) { state.fontSize = action.payload; }, + setAgentMessageViewMode(state, action: PayloadAction) { + state.agentMessageViewMode = action.payload; + }, }, }); -export const { setThemeMode, setTabBarLabels, setFontSize } = themeSlice.actions; +export const { setThemeMode, setTabBarLabels, setFontSize, setAgentMessageViewMode } = + themeSlice.actions; export default themeSlice.reducer; /** diff --git a/app/test/playwright/specs/chat-harness-subagent.spec.ts b/app/test/playwright/specs/chat-harness-subagent.spec.ts index e5bdba086..56232bc79 100644 --- a/app/test/playwright/specs/chat-harness-subagent.spec.ts +++ b/app/test/playwright/specs/chat-harness-subagent.spec.ts @@ -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 = ( diff --git a/app/test/playwright/specs/harness-composio-tool-flow.spec.ts b/app/test/playwright/specs/harness-composio-tool-flow.spec.ts index 42937da4c..f87d069e5 100644 --- a/app/test/playwright/specs/harness-composio-tool-flow.spec.ts +++ b/app/test/playwright/specs/harness-composio-tool-flow.spec.ts @@ -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( diff --git a/app/test/playwright/specs/settings-leaf-workflows.spec.ts b/app/test/playwright/specs/settings-leaf-workflows.spec.ts index 9bedc8049..06866ac4e 100644 --- a/app/test/playwright/specs/settings-leaf-workflows.spec.ts +++ b/app/test/playwright/specs/settings-leaf-workflows.spec.ts @@ -13,12 +13,16 @@ async function openSettings(page: Page, userId: string, hash: string): Promise { +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(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 ({