Mobile Conversations toggle in header + theme-aware agent bubbles (#87, #88) (#89)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 10s
Deploy Beta / deploy-beta (push) Successful in 2m0s

## Summary
- Closes [#87](#87) — move mobile Conversations toggle inline beside the Hesychia logo in the header (no overlay over chat bubbles)
- Closes [#88](#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: #89
This commit was merged in pull request #89.
This commit is contained in:
2026-08-02 03:39:35 -07:00
parent bfed056a0e
commit d5a885f42e
5 changed files with 171 additions and 54 deletions
@@ -4,7 +4,7 @@ import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from 'styled-components'; import { ThemeProvider } from 'styled-components';
import ConversationDetailCard from './ConversationDetailCard'; import ConversationDetailCard from './ConversationDetailCard';
const theme = { const darkTheme = {
main: '#336699', main: '#336699',
focus: '#224466', focus: '#224466',
darkMode: true, darkMode: true,
@@ -15,10 +15,24 @@ const theme = {
}, },
}; };
const renderCard = (props: { const lightTheme = {
message: string; main: '#336699',
user_created: boolean; 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( render(
<MemoryRouter> <MemoryRouter>
<ThemeProvider theme={theme as never}> <ThemeProvider theme={theme as never}>
@@ -80,4 +94,26 @@ describe('ConversationDetailCard', () => {
expect(screen.queryByRole('link', { name: /upgrade your plan/i })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: /upgrade your plan/i })).not.toBeInTheDocument();
expect(screen.getByText(/image generation/i)).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)');
});
}); });
@@ -29,18 +29,32 @@ const Bubble = styled.div<{ $isUser: boolean }>`
? `linear-gradient(135deg, ${props.theme.main} 0%, ${props.theme.focus} 100%)` ? `linear-gradient(135deg, ${props.theme.main} 0%, ${props.theme.focus} 100%)`
: props.theme.darkMode : props.theme.darkMode
? "rgba(255, 255, 255, 0.1)" ? "rgba(255, 255, 255, 0.1)"
: "rgba(0, 0, 0, 0.7)"}; : "rgba(0, 0, 0, 0.06)"};
color: #fff; color: ${(props) =>
props.$isUser || props.theme.darkMode ? "#fff" : props.theme.colors.text};
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border: 1px solid ${(props) => props.theme.darkMode ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.1)"}; border: 1px solid ${(props) =>
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); 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; font-size: 1rem;
line-height: 1.6; line-height: 1.6;
border-bottom-right-radius: ${(props) => (props.$isUser ? "0.2rem" : "1.2rem")}; border-bottom-right-radius: ${(props) => (props.$isUser ? "0.2rem" : "1.2rem")};
border-bottom-left-radius: ${(props) => (props.$isUser ? "1.2rem" : "0.2rem")}; border-bottom-left-radius: ${(props) => (props.$isUser ? "1.2rem" : "0.2rem")};
& pre { & 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; padding: 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
overflow-x: auto; overflow-x: auto;
@@ -53,7 +67,8 @@ const Bubble = styled.div<{ $isUser: boolean }>`
} }
& a { & a {
color: #a0c4ff; color: ${(props) =>
props.$isUser || props.theme.darkMode ? "#a0c4ff" : props.theme.main};
text-decoration: underline; text-decoration: underline;
} }
@@ -72,7 +87,7 @@ const Bubble = styled.div<{ $isUser: boolean }>`
const LoadingDot = styled.div` const LoadingDot = styled.div`
width: 8px; width: 8px;
height: 8px; height: 8px;
background: #fff; background: currentColor;
border-radius: 50%; border-radius: 50%;
margin: 0 4px; margin: 0 4px;
animation: bounce 1.4s infinite ease-in-out both; animation: bounce 1.4s infinite ease-in-out both;
@@ -29,7 +29,7 @@ const theme = {
}, },
}; };
const renderHeader = () => const renderHeader = (props: { onOpenConversations?: () => void } = {}) =>
render( render(
<MemoryRouter> <MemoryRouter>
<ThemeProvider theme={theme as never}> <ThemeProvider theme={theme as never}>
@@ -43,7 +43,7 @@ const renderHeader = () =>
}} }}
> >
<AccountContext.Provider value={{ account: undefined, setAccount: jest.fn() }}> <AccountContext.Provider value={{ account: undefined, setAccount: jest.fn() }}>
<Header2 /> <Header2 {...props} />
</AccountContext.Provider> </AccountContext.Provider>
</AuthContext.Provider> </AuthContext.Provider>
</ThemeProvider> </ThemeProvider>
@@ -95,3 +95,29 @@ describe('Header2 (#81 subscription-aware Documents nav link)', () => {
expect(await screen.findAllByText('Documents')).toHaveLength(2); 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);
});
});
@@ -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` const Logo = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
@@ -51,6 +95,8 @@ const LogoWordmark = styled.h4`
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 700; font-weight: 700;
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -139,6 +185,14 @@ const HamburgerIcon = ({ color }: { color: string }) => (
</svg> </svg>
); );
const ConversationsMenuIcon = ({ color }: { color: string }) => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M3 12H21" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M3 6H21" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M3 18H21" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
const CloseIcon = ({ color }: { color: string }) => ( const CloseIcon = ({ color }: { color: string }) => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> <path d="M18 6L6 18" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
@@ -150,9 +204,16 @@ type Header2Props = {
absolute?: Boolean; absolute?: Boolean;
light?: Boolean; light?: Boolean;
isMini?: 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 { setAuthentication } = useContext(AuthContext);
const { setAccount } = useContext(AccountContext); const { setAccount } = useContext(AccountContext);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -182,10 +243,22 @@ const Header2 = ({ absolute = false, light = false, isMini = false }: Header2Pro
return ( return (
<HeaderContainer> <HeaderContainer>
<Logo onClick={() => navigate('/')}> <BrandCluster>
<LogoMark src={hesychiaMark} alt="" /> {onOpenConversations && (
<LogoWordmark>Hesychia</LogoWordmark> <ConversationsToggle
</Logo> type="button"
onClick={onOpenConversations}
aria-label="Open conversations"
>
<ConversationsMenuIcon color={theme.colors.text} />
<ConversationsToggleLabel>Conversations</ConversationsToggleLabel>
</ConversationsToggle>
)}
<Logo onClick={() => navigate('/')}>
<LogoMark src={hesychiaMark} alt="" />
<LogoWordmark>Hesychia</LogoWordmark>
</Logo>
</BrandCluster>
{/* Desktop Nav */} {/* Desktop Nav */}
<Nav> <Nav>
@@ -2,7 +2,7 @@ import React, { useContext, useEffect, useRef, useState } from "react";
import styled, { ThemeContext } from "styled-components"; import styled, { ThemeContext } from "styled-components";
import { Formik, Form, Field } from "formik"; import { Formik, Form, Field } from "formik";
import * as Yup from "yup"; import * as Yup from "yup";
import { AttachFile, Delete, Send, Menu, Close } from "@mui/icons-material"; // Keeping icons for now, can replace later if needed import { AttachFile, Delete, Send, Close } from "@mui/icons-material"; // Keeping icons for now, can replace later if needed
import { Tooltip } from "@mui/material"; import { Tooltip } from "@mui/material";
import Markdown from "markdown-to-jsx"; import Markdown from "markdown-to-jsx";
@@ -63,34 +63,6 @@ const Sidebar = styled.div<{ $isOpen: boolean }>`
} }
`; `;
const MobileSidebarToggle = styled.button`
display: none;
position: absolute;
top: calc(4.75rem + env(safe-area-inset-top, 0px)); /* Below header */
left: 1rem;
z-index: 15;
padding: 0.5rem 1rem;
background: ${({ theme }) => theme.main};
color: white;
border: none;
border-radius: 2rem;
font-weight: 600;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0,0,0,0.3);
}
@media (max-width: 768px) {
display: flex;
align-items: center;
gap: 0.5rem;
}
`;
const Overlay = styled.div<{ $isOpen: boolean }>` const Overlay = styled.div<{ $isOpen: boolean }>`
display: none; display: none;
position: fixed; position: fixed;
@@ -454,15 +426,10 @@ const AsyncDashboardInner = (): JSX.Element => {
return ( return (
<PageContainer> <PageContainer>
<ParticleBackground /> <ParticleBackground />
<Header2 /> <Header2 onOpenConversations={() => setIsSidebarOpen(true)} />
<Overlay $isOpen={isSidebarOpen} onClick={() => setIsSidebarOpen(false)} /> <Overlay $isOpen={isSidebarOpen} onClick={() => setIsSidebarOpen(false)} />
<MobileSidebarToggle onClick={() => setIsSidebarOpen(true)}>
<Menu fontSize="small" />
<span>Conversations</span>
</MobileSidebarToggle>
<Sidebar $isOpen={isSidebarOpen}> <Sidebar $isOpen={isSidebarOpen}>
<MobileSidebarHeader> <MobileSidebarHeader>
<span>Conversations</span> <span>Conversations</span>