feat: add assistant response view setting (#3455)

This commit is contained in:
Steven Enamakel
2026-06-06 23:24:14 -04:00
committed by GitHub
parent bc1674b60f
commit ece264ff8f
26 changed files with 637 additions and 250 deletions
@@ -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>
);
+4
View File
@@ -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': 'عنوان الشخصية',
+4
View File
@@ -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': 'চরিত্রের শিরোনাম',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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': 'कैरेक्टर शीर्षक',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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': '캐릭터 제목',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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': 'Персонаж',
+3
View File
@@ -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
View File
@@ -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>
);
}
+1 -1
View File
@@ -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);
+18 -1
View File
@@ -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', () => {
+13 -2
View File
@@ -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 ({