Move mobile Conversations toggle into header; theme agent bubbles (#87, #88)
Unit Tests / test (pull_request) Successful in 11s
Unit Tests / test (pull_request) Successful in 11s
Put the conversations drawer control inline beside the Hesychia logo so it no longer overlays chat text, and make agent message bubbles follow light/dark theme with readable contrast.
This commit is contained in:
+39
-3
@@ -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 = {
|
||||||
|
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;
|
message: string;
|
||||||
user_created: boolean;
|
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>
|
||||||
|
<BrandCluster>
|
||||||
|
{onOpenConversations && (
|
||||||
|
<ConversationsToggle
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenConversations}
|
||||||
|
aria-label="Open conversations"
|
||||||
|
>
|
||||||
|
<ConversationsMenuIcon color={theme.colors.text} />
|
||||||
|
<ConversationsToggleLabel>Conversations</ConversationsToggleLabel>
|
||||||
|
</ConversationsToggle>
|
||||||
|
)}
|
||||||
<Logo onClick={() => navigate('/')}>
|
<Logo onClick={() => navigate('/')}>
|
||||||
<LogoMark src={hesychiaMark} alt="" />
|
<LogoMark src={hesychiaMark} alt="" />
|
||||||
<LogoWordmark>Hesychia</LogoWordmark>
|
<LogoWordmark>Hesychia</LogoWordmark>
|
||||||
</Logo>
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user