[AI Chat]: Move sidebar animation to CSS (#26950)

This commit is contained in:
Jay Harris
2026-03-05 01:55:54 +00:00
committed by GitHub
parent ff9240d6de
commit bd45e25ceb
4 changed files with 82 additions and 114 deletions
@@ -115,32 +115,35 @@ function ConversationItem(props: ConversationItemProps) {
{title}
</div>
</div>
<ButtonMenu
className={styles.optionsMenu}
onChange={handleButtonMenuChange}
>
<Button
slot='anchor-content'
className={styles.optionsButton}
kind='plain-faint'
fab
size='small'
{/* Stop propagation so clicks don't bubble to the Link and close the sidebar */}
<div onClick={(e) => e.stopPropagation()}>
<ButtonMenu
className={styles.optionsMenu}
onChange={handleButtonMenuChange}
>
<Icon name='more-vertical' />
</Button>
<leo-menu-item onClick={handleEditTitle}>
<div className={styles.optionsMenuItemWithIcon}>
<Icon name='edit-pencil' />
<div>{getLocale(S.CHAT_UI_MENU_RENAME_CONVERSATION)}</div>
</div>
</leo-menu-item>
<leo-menu-item onClick={handleDelete}>
<div className={styles.optionsMenuItemWithIcon}>
<Icon name='trash' />
<div>{getLocale(S.CHAT_UI_MENU_DELETE_CONVERSATION)}</div>
</div>
</leo-menu-item>
</ButtonMenu>
<Button
slot='anchor-content'
className={styles.optionsButton}
kind='plain-faint'
fab
size='small'
>
<Icon name='more-vertical' />
</Button>
<leo-menu-item onClick={handleEditTitle}>
<div className={styles.optionsMenuItemWithIcon}>
<Icon name='edit-pencil' />
<div>{getLocale(S.CHAT_UI_MENU_RENAME_CONVERSATION)}</div>
</div>
</leo-menu-item>
<leo-menu-item onClick={handleDelete}>
<div className={styles.optionsMenuItemWithIcon}>
<Icon name='trash' />
<div>{getLocale(S.CHAT_UI_MENU_DELETE_CONVERSATION)}</div>
</div>
</leo-menu-item>
</ButtonMenu>
</div>
</div>
{uuid === aiChatContext.editingConversationId && (
<div className={styles.editibleTitle}>
@@ -20,110 +20,54 @@ export default function FullScreen() {
const { createNewConversation } = useActiveChat()
const conversationContext = useConversation()
const asideAnimationRef = React.useRef<Animation | null>()
const controllerRef = React.useRef(new AbortController())
const asideRef = React.useRef<HTMLElement | null>(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<HTMLElement | null>(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.
// <if expr="is_ios">
React.useEffect(() => {
const isClosed = asideAnimationRef.current?.playbackRate === 1
if (aiChatContext.deletingConversationId && !isClosed) {
if (aiChatContext.deletingConversationId && aiChatContext.showSidebar) {
aiChatContext.toggleSidebar()
}
}, [aiChatContext.deletingConversationId])
// </if>
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() {
>
<Icon name='window-tabs-vertical-expanded' />
</Button>
{!isNavigationRendered && canStartNewConversation && (
{!aiChatContext.showSidebar && canStartNewConversation && (
<>
<Button
fab
@@ -188,17 +132,23 @@ export default function FullScreen() {
</div>
)}
<aside
ref={initAsideAnimation}
className={styles.aside}
>
{isNavigationRendered && (
<div className={styles.nav}>
<NavigationHeader />
<ConversationsList
setIsConversationsListOpen={setIsNavigationCollapsed}
/>
</div>
ref={asideRef}
className={classNames(
styles.aside,
aiChatContext.showSidebar && styles.open,
isAnimated && styles.animated,
)}
>
<div className={styles.nav}>
<NavigationHeader />
<ConversationsList
setIsConversationsListOpen={(open) => {
if (!open && aiChatContext.showSidebar) {
aiChatContext.toggleSidebar()
}
}}
/>
</div>
</aside>
</div>
<div className={styles.content}>
@@ -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 {
@@ -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<Mojom.Skill | null>(null)
const { getConversationsData, isPlaceholderData: isConversationsLoading } =