From d5a885f42eb153cf92702c596f759e4b8b6dd77b Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Sun, 2 Aug 2026 03:39:35 -0700 Subject: [PATCH] Mobile Conversations toggle in header + theme-aware agent bubbles (#87, #88) (#89) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Closes [#87](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/87) — move mobile Conversations toggle inline beside the Hesychia logo in the header (no overlay over chat bubbles) - Closes [#88](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/88) — agent message bubbles follow light/dark theme with correct text contrast; user bubbles unchanged ## Changes - `Header2`: optional `onOpenConversations` renders compact mobile toggle next to brand; label hides on very narrow screens to avoid crowding the nav hamburger - `AsyncDashboard2`: wire drawer open via header callback; remove floating `MobileSidebarToggle` - `ConversationDetailCard`: light-mode agent bubble uses light background + theme text color; dark mode keeps dark translucent bubble + light text ## Test plan - [ ] Mobile: Conversations control appears beside Hesychia logo; opens/closes drawer as before - [ ] Mobile: chat message text no longer obscured by floating Conversations chip - [ ] Narrow phone width: header usable with logo + toggle + hamburger (no overflow) - [ ] Light mode: agent bubbles light with dark text; user bubbles still gradient - [ ] Dark mode: agent bubbles dark with light text; readability OK on long replies - [ ] Unit: `Header2` + `ConversationDetailCard` tests passReviewed-on: https://git.aimloperations.com/ai_ml_operations/chat_web_app/pulls/89 --- .../ConversationDetailCard.test.tsx | 46 ++++++++-- .../ConversationDetailCard.tsx | 29 +++++-- .../components/Header2/Header2.test.tsx | 30 ++++++- .../src/llm-fe/components/Header2/Header2.tsx | 83 +++++++++++++++++-- .../pages/AsyncDashboard2/AsyncDashboard2.tsx | 37 +-------- 5 files changed, 171 insertions(+), 54 deletions(-) diff --git a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.test.tsx b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.test.tsx index 66dfde0..3f67536 100644 --- a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.test.tsx +++ b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.test.tsx @@ -4,7 +4,7 @@ import { MemoryRouter } from 'react-router-dom'; import { ThemeProvider } from 'styled-components'; import ConversationDetailCard from './ConversationDetailCard'; -const theme = { +const darkTheme = { main: '#336699', focus: '#224466', darkMode: true, @@ -15,10 +15,24 @@ const theme = { }, }; -const renderCard = (props: { - message: string; - user_created: boolean; -}) => +const lightTheme = { + main: '#336699', + focus: '#224466', + darkMode: false, + colors: { + text: '#111111', + cardBackground: 'rgba(255,255,255,0.8)', + cardBorder: 'rgba(0,0,0,0.1)', + }, +}; + +const renderCard = ( + props: { + message: string; + user_created: boolean; + }, + theme: typeof darkTheme = darkTheme +) => render( @@ -80,4 +94,26 @@ describe('ConversationDetailCard', () => { expect(screen.queryByRole('link', { name: /upgrade your plan/i })).not.toBeInTheDocument(); expect(screen.getByText(/image generation/i)).toBeInTheDocument(); }); + + it('uses theme text color for agent bubble in light mode (#88)', () => { + renderCard( + { message: 'Light mode reply', user_created: false }, + lightTheme + ); + const message = screen.getByText('Light mode reply'); + const agentBubble = message.closest('div'); + expect(agentBubble).not.toBeNull(); + expect(getComputedStyle(agentBubble!).color).toBe('rgb(17, 17, 17)'); + }); + + it('keeps light agent text in dark mode (#88)', () => { + renderCard( + { message: 'Dark mode reply', user_created: false }, + darkTheme + ); + const message = screen.getByText('Dark mode reply'); + const agentBubble = message.closest('div'); + expect(agentBubble).not.toBeNull(); + expect(getComputedStyle(agentBubble!).color).toBe('rgb(255, 255, 255)'); + }); }); diff --git a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx index 58bc55f..f65b23c 100644 --- a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx +++ b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx @@ -29,18 +29,32 @@ const Bubble = styled.div<{ $isUser: boolean }>` ? `linear-gradient(135deg, ${props.theme.main} 0%, ${props.theme.focus} 100%)` : props.theme.darkMode ? "rgba(255, 255, 255, 0.1)" - : "rgba(0, 0, 0, 0.7)"}; - color: #fff; + : "rgba(0, 0, 0, 0.06)"}; + color: ${(props) => + props.$isUser || props.theme.darkMode ? "#fff" : props.theme.colors.text}; backdrop-filter: blur(10px); - border: 1px solid ${(props) => props.theme.darkMode ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.1)"}; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); + border: 1px solid ${(props) => + props.$isUser + ? props.theme.darkMode + ? "rgba(255, 255, 255, 0.1)" + : "rgba(0, 0, 0, 0.1)" + : props.theme.darkMode + ? "rgba(255, 255, 255, 0.1)" + : "rgba(0, 0, 0, 0.08)"}; + box-shadow: ${(props) => + props.$isUser || props.theme.darkMode + ? "0 4px 15px rgba(0, 0, 0, 0.2)" + : "0 2px 10px rgba(0, 0, 0, 0.08)"}; font-size: 1rem; line-height: 1.6; border-bottom-right-radius: ${(props) => (props.$isUser ? "0.2rem" : "1.2rem")}; border-bottom-left-radius: ${(props) => (props.$isUser ? "1.2rem" : "0.2rem")}; & pre { - background: rgba(0, 0, 0, 0.3); + background: ${(props) => + props.$isUser || props.theme.darkMode + ? "rgba(0, 0, 0, 0.3)" + : "rgba(0, 0, 0, 0.06)"}; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; @@ -53,7 +67,8 @@ const Bubble = styled.div<{ $isUser: boolean }>` } & a { - color: #a0c4ff; + color: ${(props) => + props.$isUser || props.theme.darkMode ? "#a0c4ff" : props.theme.main}; text-decoration: underline; } @@ -72,7 +87,7 @@ const Bubble = styled.div<{ $isUser: boolean }>` const LoadingDot = styled.div` width: 8px; height: 8px; - background: #fff; + background: currentColor; border-radius: 50%; margin: 0 4px; animation: bounce 1.4s infinite ease-in-out both; diff --git a/llm-fe/src/llm-fe/components/Header2/Header2.test.tsx b/llm-fe/src/llm-fe/components/Header2/Header2.test.tsx index 0e3ddc5..eb11527 100644 --- a/llm-fe/src/llm-fe/components/Header2/Header2.test.tsx +++ b/llm-fe/src/llm-fe/components/Header2/Header2.test.tsx @@ -29,7 +29,7 @@ const theme = { }, }; -const renderHeader = () => +const renderHeader = (props: { onOpenConversations?: () => void } = {}) => render( @@ -43,7 +43,7 @@ const renderHeader = () => }} > - + @@ -95,3 +95,29 @@ describe('Header2 (#81 subscription-aware Documents nav link)', () => { expect(await screen.findAllByText('Documents')).toHaveLength(2); }); }); + +describe('Header2 (#87 conversations toggle beside logo)', () => { + beforeEach(() => { + mockGet.mockReset(); + mockPost.mockReset(); + resetSubscriptionCache(); + mockGet.mockResolvedValue(subscriptionWithRag(false)); + }); + + it('does not render conversations toggle when callback is omitted', () => { + renderHeader(); + expect(screen.queryByRole('button', { name: /open conversations/i })).not.toBeInTheDocument(); + }); + + it('renders conversations toggle next to brand when callback is provided', () => { + const onOpenConversations = jest.fn(); + renderHeader({ onOpenConversations }); + + // Mobile-only via CSS (display:none in jsdom desktop width); query by aria-label. + const toggle = screen.getByLabelText('Open conversations'); + expect(toggle).toBeInTheDocument(); + expect(screen.getByText('Hesychia')).toBeInTheDocument(); + toggle.click(); + expect(onOpenConversations).toHaveBeenCalledTimes(1); + }); +}); diff --git a/llm-fe/src/llm-fe/components/Header2/Header2.tsx b/llm-fe/src/llm-fe/components/Header2/Header2.tsx index 98f41eb..08c5410 100644 --- a/llm-fe/src/llm-fe/components/Header2/Header2.tsx +++ b/llm-fe/src/llm-fe/components/Header2/Header2.tsx @@ -31,6 +31,50 @@ const HeaderContainer = styled.header` } `; +const BrandCluster = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + flex: 1; +`; + +const ConversationsToggle = styled.button` + display: none; + flex-shrink: 0; + align-items: center; + justify-content: center; + gap: 0.35rem; + margin: 0; + padding: 0.35rem 0.55rem; + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 0.65rem; + background: ${({ theme }) => + theme.darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.06)'}; + color: ${({ theme }) => theme.colors.text}; + cursor: pointer; + transition: background 0.2s ease; + + &:hover { + background: ${({ theme }) => + theme.darkMode ? 'rgba(255, 255, 255, 0.14)' : 'rgba(0, 0, 0, 0.1)'}; + } + + @media (max-width: 768px) { + display: inline-flex; + } +`; + +const ConversationsToggleLabel = styled.span` + font-size: 0.8rem; + font-weight: 600; + white-space: nowrap; + + @media (max-width: 380px) { + display: none; + } +`; + const Logo = styled.div` display: flex; align-items: center; @@ -51,6 +95,8 @@ const LogoWordmark = styled.h4` margin: 0; font-size: 1.5rem; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; font-weight: 700; @media (max-width: 768px) { @@ -139,6 +185,14 @@ const HamburgerIcon = ({ color }: { color: string }) => ( ); +const ConversationsMenuIcon = ({ color }: { color: string }) => ( + +); + const CloseIcon = ({ color }: { color: string }) => ( @@ -150,9 +204,16 @@ type Header2Props = { absolute?: Boolean; light?: Boolean; isMini?: Boolean; + /** Mobile: open conversations drawer. Rendered inline beside logo when set. */ + onOpenConversations?: () => void; } -const Header2 = ({ absolute = false, light = false, isMini = false }: Header2Props): JSX.Element => { +const Header2 = ({ + absolute = false, + light = false, + isMini = false, + onOpenConversations, +}: Header2Props): JSX.Element => { const { setAuthentication } = useContext(AuthContext); const { setAccount } = useContext(AccountContext); const navigate = useNavigate(); @@ -182,10 +243,22 @@ const Header2 = ({ absolute = false, light = false, isMini = false }: Header2Pro return ( - navigate('/')}> - - Hesychia - + + {onOpenConversations && ( + + + Conversations + + )} + navigate('/')}> + + Hesychia + + {/* Desktop Nav */} - Conversations - - Conversations