diff --git a/components/ai_chat/resources/page/components/conversations_list/index.tsx b/components/ai_chat/resources/page/components/conversations_list/index.tsx index 96bece4d647..4fbe61beceb 100644 --- a/components/ai_chat/resources/page/components/conversations_list/index.tsx +++ b/components/ai_chat/resources/page/components/conversations_list/index.tsx @@ -115,32 +115,35 @@ function ConversationItem(props: ConversationItemProps) { {title} - - - -
- -
{getLocale(S.CHAT_UI_MENU_RENAME_CONVERSATION)}
-
-
- -
- -
{getLocale(S.CHAT_UI_MENU_DELETE_CONVERSATION)}
-
-
-
+ + +
+ +
{getLocale(S.CHAT_UI_MENU_RENAME_CONVERSATION)}
+
+
+ +
+ +
{getLocale(S.CHAT_UI_MENU_DELETE_CONVERSATION)}
+
+
+ + {uuid === aiChatContext.editingConversationId && (
diff --git a/components/ai_chat/resources/page/components/full_page/index.tsx b/components/ai_chat/resources/page/components/full_page/index.tsx index 2dd95b65b78..c4b056ea30d 100644 --- a/components/ai_chat/resources/page/components/full_page/index.tsx +++ b/components/ai_chat/resources/page/components/full_page/index.tsx @@ -20,110 +20,54 @@ export default function FullScreen() { const { createNewConversation } = useActiveChat() const conversationContext = useConversation() - const asideAnimationRef = React.useRef() - const controllerRef = React.useRef(new AbortController()) + const asideRef = React.useRef(null) const isSmall = useIsSmall() - const [isNavigationCollapsed, setIsNavigationCollapsed] = - React.useState(isSmall) - const [isNavigationRendered, setIsNavigationRendered] = - React.useState(!isSmall) + + // Enable CSS transitions after the first paint to avoid animating on mount + const [isAnimated, setIsAnimated] = React.useState(false) + React.useEffect(() => { + const frame = requestAnimationFrame(() => setIsAnimated(true)) + return () => cancelAnimationFrame(frame) + }, []) const canStartNewConversation = aiChatContext.hasAcceptedAgreement && !!conversationContext.conversationHistory.length - const asideRef = React.useRef(null) - - const initAsideAnimation = React.useCallback((node: HTMLElement | null) => { - asideRef.current = node - - if (!node) return - const open = { width: 'var(--navigation-width)', opacity: 1 } - const close = { width: '0px', opacity: 0 } - const animationOptions: KeyframeAnimationOptions = { - duration: 400, - easing: 'cubic-bezier(0.77, 0, 0.175, 1)', - fill: 'forwards', - } - asideAnimationRef.current = new Animation( - new KeyframeEffect(node, [open, close], animationOptions), - ) - - // Make sure we're in the right state for our screen size when - asideAnimationRef.current.playbackRate = isSmall ? 1 : -1 - asideAnimationRef.current.finish() - }, []) - - const toggleAside = () => { - const asideAnimation = asideAnimationRef.current - - if (asideAnimation) { - if (isNavigationCollapsed) { - controllerRef.current.abort() - controllerRef.current = new AbortController() - asideAnimation.ready.then(() => setIsNavigationRendered(true)) - asideAnimation.playbackRate = -1 - } else { - // 'finish' triggers in both directions, so we only need this once per close animation - // user may rapidly toggle the aside, so we need to abort scheduled listener in open animation - asideAnimation.addEventListener( - 'finish', - () => setIsNavigationRendered(false), - { once: true, signal: controllerRef.current.signal }, - ) - asideAnimation.playbackRate = 1 - } - - asideAnimation.play() - setIsNavigationCollapsed(!isNavigationCollapsed) - } - } - + // When editing a conversation title, ensure the sidebar is open React.useEffect(() => { - const isOpen = asideAnimationRef.current?.playbackRate === 1 - if (aiChatContext.editingConversationId && isOpen) { - toggleAside() + if (aiChatContext.editingConversationId && !aiChatContext.showSidebar) { + aiChatContext.toggleSidebar() } - }, [aiChatContext.editingConversationId, isNavigationCollapsed]) + }, [aiChatContext.editingConversationId]) // On iOS, close the sidebar when the delete-conversation dialog opens. // The sidebar overlaps the dialog and intercepts touch events, requiring - // a double-tap to reach buttons inside the dialog. Uses toggleSidebar() - // (not toggleAside()) so showSidebar stays in sync with the visual state; - // the showSidebar effect above then drives the actual animation. + // a double-tap to reach buttons inside a dialog. Uses toggleSidebar() + // so showSidebar stays in sync with the visual state. // React.useEffect(() => { - const isClosed = asideAnimationRef.current?.playbackRate === 1 - if (aiChatContext.deletingConversationId && !isClosed) { + if (aiChatContext.deletingConversationId && aiChatContext.showSidebar) { aiChatContext.toggleSidebar() } }, [aiChatContext.deletingConversationId]) // React.useEffect(() => { - const isOpen = asideAnimationRef.current?.playbackRate === 1 - // We've just changed to small and the sidebar was open, so close it - if (isSmall && !isOpen) { + if (isSmall && aiChatContext.showSidebar) { aiChatContext.toggleSidebar() } // We've just changed to big and the sidebar was closed, so open it - if (!isSmall && isOpen) { + if (!isSmall && !aiChatContext.showSidebar) { aiChatContext.toggleSidebar() } }, [isSmall]) - React.useEffect(() => { - const isOpen = asideAnimationRef.current?.playbackRate === 1 - if (isOpen !== aiChatContext.showSidebar) { - toggleAside() - } - }, [aiChatContext.showSidebar]) - // Add handler for closing the sidebar when clicking outside of it. React.useEffect(() => { - if (aiChatContext.showSidebar || !isSmall) return + if (!aiChatContext.showSidebar || !isSmall) return const handleClick = (e: MouseEvent) => { const path = e.composedPath() // On iOS, the one-tap fix dispatches synthetic clicks at (0,0). @@ -173,7 +117,7 @@ export default function FullScreen() { > - {!isNavigationRendered && canStartNewConversation && ( + {!aiChatContext.showSidebar && canStartNewConversation && ( <>
diff --git a/components/ai_chat/resources/page/components/full_page/style.module.scss b/components/ai_chat/resources/page/components/full_page/style.module.scss index cc2d0698661..2e2d06b2c62 100644 --- a/components/ai_chat/resources/page/components/full_page/style.module.scss +++ b/components/ai_chat/resources/page/components/full_page/style.module.scss @@ -42,10 +42,27 @@ .aside { --navigation-width: 340px; - width: var(--navigation-width); + --animation-duration: 200ms; + width: 0; + opacity: 0; + visibility: hidden; height: 100%; overflow: hidden; background: var(--leo-color-page-background); + padding-top: var(--top-padding); + + &.open { + width: var(--navigation-width); + opacity: 1; + visibility: visible; + } + + &.animated { + transition: + width var(--animation-duration) cubic-bezier(0.77, 0, 0.175, 1), + opacity var(--animation-duration) cubic-bezier(0.77, 0, 0.175, 1), + visibility var(--animation-duration) allow-discrete; + } @media (max-width: 1024px) { position: fixed; @@ -56,8 +73,6 @@ 0px var(--leo-elevation-xs, 4px) var(--leo-elevation-xs, 16px) -2px var(--leo-color-elevation-secondary, rgba(0, 0, 0, 0.1)); } - - padding-top: var(--top-padding); } .nav { diff --git a/components/ai_chat/resources/page/state/ai_chat_context.tsx b/components/ai_chat/resources/page/state/ai_chat_context.tsx index 9f2022368af..25e9c672495 100644 --- a/components/ai_chat/resources/page/state/ai_chat_context.tsx +++ b/components/ai_chat/resources/page/state/ai_chat_context.tsx @@ -39,7 +39,7 @@ export default function useProvideAIChatContext(props: AIChatContextProps) { string | null >(null) const isSmall = useIsSmall() - const [showSidebar, setShowSidebar] = React.useState(isSmall) + const [showSidebar, setShowSidebar] = React.useState(!isSmall) const [skillDialog, setSkillDialog] = React.useState(null) const { getConversationsData, isPlaceholderData: isConversationsLoading } =