From 5631c69c8c0b1fba9cc285b215f4a22d5cf92cf0 Mon Sep 17 00:00:00 2001 From: Chris Roth Date: Wed, 22 Jul 2026 17:39:09 -0400 Subject: [PATCH 01/14] feat(design): refine mobile sidebar and controls Improve mobile navigation consistency and preserve legibility around floating and scrollable surfaces. Co-authored-by: Cursor --- src/components/floating-header.tsx | 14 ++-- src/components/sidebar-footer.test.tsx | 17 +++-- src/components/sidebar-footer.tsx | 64 ++++++++--------- src/components/ui/mobile-sidebar.test.tsx | 6 ++ src/components/ui/mobile-sidebar.tsx | 10 +-- src/index.css | 6 +- src/layout/sidebar/chat-actions.tsx | 12 ++-- src/layout/sidebar/chat-list.tsx | 60 +++++++++++++++- src/layout/sidebar/chat-sidebar.tsx | 87 +++++++++++++---------- src/layout/sidebar/nav-toggle.tsx | 6 +- src/layout/sidebar/settings-sidebar.tsx | 9 ++- src/layout/sidebar/sidebar-header.tsx | 5 +- src/layout/sidebar/types.ts | 6 +- src/settings/models/index.tsx | 12 ++-- 14 files changed, 194 insertions(+), 120 deletions(-) diff --git a/src/components/floating-header.tsx b/src/components/floating-header.tsx index 1d759d021..1c23cc730 100644 --- a/src/components/floating-header.tsx +++ b/src/components/floating-header.tsx @@ -10,10 +10,8 @@ import { Header } from '@/components/ui/header' * * The scrim fades the page background from the very top of the viewport down * past the floating header, so content scrolling beneath stays legible behind - * the header controls. Painted background-on-background, it is invisible - * until content actually scrolls under it. It holds full opacity through the - * top 40% (where the header buttons sit) before fading, so text scrolling - * underneath can't bleed through the controls. + * the header controls. A subtle backdrop blur softens content passing behind + * the controls, then fades with the scrim so there is no hard blur boundary. * * The header floats over the content instead of consuming layout height — * pages own the full viewport and pad by `--header-inset` where needed. @@ -21,8 +19,12 @@ import { Header } from '@/components/ui/header' export const FloatingHeader = () => ( <>
diff --git a/src/components/sidebar-footer.test.tsx b/src/components/sidebar-footer.test.tsx index 4636a4cd2..7985d3898 100644 --- a/src/components/sidebar-footer.test.tsx +++ b/src/components/sidebar-footer.test.tsx @@ -53,12 +53,15 @@ const renderWithProviders = (authClient: AuthClient) => { describe('SidebarFooter', () => { describe('anonymous users', () => { - it('shows the Sign In affordance (treats anonymous as logged-out)', () => { + it('shows an icon-only Sign In affordance (treats anonymous as logged-out)', () => { const authClient = createMockAuthClient({ session: { user: { id: 'anon-1', email: 'temp@anon.com', name: 'Anonymous', isAnonymous: true } }, }) renderWithProviders(authClient) - expect(screen.getByText('Sign in')).toBeInTheDocument() + const button = screen.getByRole('button', { name: 'Sign in' }) + expect(button).toBeInTheDocument() + expect(button.textContent).toBe('') + expect(button.className).toContain('w-[var(--touch-height-lg)]') }) it('does NOT leak the synthetic anonymous email into the UI', () => { @@ -85,7 +88,7 @@ describe('SidebarFooter', () => { }) renderWithProviders(authClient) expect(screen.getByText('Real User')).toBeInTheDocument() - expect(screen.queryByText('Sign in')).toBeNull() + expect(screen.queryByRole('button', { name: 'Sign in' })).toBeNull() }) it('falls back to the email when the account has no display name', () => { @@ -105,16 +108,18 @@ describe('SidebarFooter', () => { }) renderWithProviders(authClient) const button = screen.getByRole('button', { name: 'Account menu' }) - expect(button.className).toContain('size-[var(--touch-height-default)]') + expect(button.className).toContain('h-[var(--touch-height-lg)]') + expect(button.className).toContain('w-[var(--touch-height-lg)]') expect(button.querySelector('span')).toBeNull() }) }) describe('fully logged-out users', () => { - it('shows the Sign In affordance', () => { + it('shows the icon-only Sign In affordance', () => { const authClient = createMockAuthClient({ session: null }) renderWithProviders(authClient) - expect(screen.getByText('Sign in')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument() + expect(screen.queryByText('Sign in')).toBeNull() }) }) diff --git a/src/components/sidebar-footer.tsx b/src/components/sidebar-footer.tsx index c07f83bc5..d8aa135e0 100644 --- a/src/components/sidebar-footer.tsx +++ b/src/components/sidebar-footer.tsx @@ -25,7 +25,6 @@ import type { User } from '@shared/types/auth' import { LogoutModal } from '@/components/logout-modal' import { BrandGradientIcon } from '@/components/ui/brand-gradient-icon' import { SyncSetupModal } from '@/components/sync-setup/sync-setup-modal' -import { ThemeToggle } from '@/components/theme-toggle' import { Button } from '@/components/ui/button' import { MobileBlurBackdrop } from '@/components/ui/mobile-blur-backdrop' import { NavLink } from '@/components/ui/nav-link' @@ -48,10 +47,7 @@ const openLink = (url: string) => window.open(url, '_blank', 'noopener,noreferre type SidebarFooterProps = { className?: string - /** Chats/Settings pill. On the mobile overlay it renders here, at the right - * of the footer row, so section switching sits in thumb reach; desktop - * ignores it (the pill lives in the sidebar header there). */ - navToggle?: ReactNode + hasContentBelow?: boolean } type AccountMenuItem = { @@ -98,7 +94,7 @@ const GradientCloud = ({ className }: { className?: string }) => ( /** * Single cloud glyph carrying both auth and sync state: - * - logged out → muted outline cloud (paired with a "Sign in" label) + * - logged out → muted outline cloud * - logged in, sync off → muted CloudOff ("connected account, not syncing") * - syncing, connecting → spinner * - syncing, offline → amber CloudAlert ("will sync when back online") @@ -153,7 +149,7 @@ export const syncStatusText = ( return 'Connected' } -export const SidebarFooter = ({ className, navToggle }: SidebarFooterProps) => { +export const SidebarFooter = ({ className, hasContentBelow = false }: SidebarFooterProps) => { const authClient = useAuth() const navigate = useNavigate() const { isMobile, setOpenMobile, state } = useSidebar() @@ -214,16 +210,14 @@ export const SidebarFooter = ({ className, navToggle }: SidebarFooterProps) => { const stateIcon = - // Accounts without a name/email label collapse to an icon-only control; it - // must be a perfect circle matching the theme toggle beside it. + // Accounts without a name/email label collapse to an icon-only perfect circle. const accountLabel = (displayName ?? displayEmail ?? '').trim() - // Same height as the theme toggle beside it; full-radius, hugging its - // content on the left edge of the footer. + // Full-radius and sized to hug its content on the left edge of the footer. const pillClassName = (hasLabel: boolean) => cn( - 'flex h-[var(--touch-height-default)] max-w-full min-w-0 cursor-pointer items-center rounded-full', - hasLabel ? 'w-fit gap-2 px-3' : 'size-[var(--touch-height-default)] justify-center', + 'flex h-[var(--touch-height-lg)] max-w-full min-w-0 cursor-pointer items-center rounded-full md:h-[var(--touch-height-default)]', + hasLabel ? 'w-fit gap-2 px-3' : 'w-[var(--touch-height-lg)] justify-center md:w-[var(--touch-height-default)]', 'text-[length:var(--font-size-body)] transition-colors outline-none', 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', menuOpen && 'bg-sidebar-accent text-sidebar-accent-foreground', @@ -240,9 +234,8 @@ export const SidebarFooter = ({ className, navToggle }: SidebarFooterProps) => { } if (!user) { return ( - ) } @@ -260,8 +253,6 @@ export const SidebarFooter = ({ className, navToggle }: SidebarFooterProps) => { ) } - // Collapsed desktop rail: the theme toggle stacks above the account/sync - // button so both stay reachable at icon-rail width. const collapsedButtonClass = 'flex size-[var(--touch-height-default)] cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-sidebar-accent' const collapsedControl = !user ? ( @@ -287,12 +278,16 @@ export const SidebarFooter = ({ className, navToggle }: SidebarFooterProps) => { return ( - + {isDesktopCollapsed ? ( -
- {/* Dev-only quick toggle; users switch themes in Preferences → - User Experience. */} - {import.meta.env.DEV && } +
{isPending ? (
@@ -302,28 +297,25 @@ export const SidebarFooter = ({ className, navToggle }: SidebarFooterProps) => { )}
) : ( -
-
{renderAccountControl()}
-
- {/* Dev-only quick toggle; users switch themes in Preferences → - User Experience. */} - {import.meta.env.DEV && } - {isMobile && navToggle} - {/* Mobile-only: desktop's New Chat list item covers this. Brand - gradient, matching the primary Button variant. */} - {isMobile && ( + <> + {isMobile ? ( +
+
{renderAccountControl()}
- )} -
-
+
+ ) : ( +
{renderAccountControl()}
+ )} + )} diff --git a/src/components/ui/mobile-sidebar.test.tsx b/src/components/ui/mobile-sidebar.test.tsx index c17ca9405..4d670f91d 100644 --- a/src/components/ui/mobile-sidebar.test.tsx +++ b/src/components/ui/mobile-sidebar.test.tsx @@ -90,6 +90,12 @@ const flushAnimations = async () => { } describe('MobileSidebar', () => { + it('blurs the content behind the open drawer', () => { + render( {}} />) + + expect(getOverlay()).toHaveClass('backdrop-blur-sm') + }) + it('closes via onOpenChange(false) when the overlay is tapped', async () => { const onOpenChange = mock() render() diff --git a/src/components/ui/mobile-sidebar.tsx b/src/components/ui/mobile-sidebar.tsx index 14e5d21b0..1ec3c1db7 100644 --- a/src/components/ui/mobile-sidebar.tsx +++ b/src/components/ui/mobile-sidebar.tsx @@ -126,15 +126,11 @@ export const MobileSidebar = ({ return ( - {/* Dim overlay — plain opacity fade (compositor-only). Intentionally NOT blurred: - unlike the app's Radix sheet/dialog overlays (bg-black/50 backdrop-blur-md), this - skips backdrop-filter because animating opacity on a blur layer forces a per-frame - re-blur of the scene behind it — the main source of close-animation jank on mobile. - Exception: the macOS desktop build restores the blur via a `.mac-vibrancy` rule in - index.css (desktop GPUs absorb the re-blur cost). */} + {/* Blur and dim the content behind the mobile drawer while fading the + overlay with the drawer's position. */} diff --git a/src/index.css b/src/index.css index dcbbc5dd9..b58c2aebe 100644 --- a/src/index.css +++ b/src/index.css @@ -194,12 +194,12 @@ --header-inset: calc(var(--safe-area-top-padding, 0px) + var(--touch-height-xl)); /* Switch component specific */ - --switch-track-height: 1.5rem; /* 24px mobile - slightly larger for touch */ - --switch-track-width: 2.75rem; /* 44px mobile */ + --switch-track-height: 1.75rem; /* 28px mobile */ + --switch-track-width: 3rem; /* 48px mobile */ /* Track = 1px border + 2px inner padding; thumb fills exactly what's left so the * 2px gap to the border is identical on every side and in every state. Whole-px * values only, so the dot never shifts size as it slides. */ - --switch-thumb-size: 1.125rem; /* 18px mobile (24 - 2*border - 2*pad) */ + --switch-thumb-size: 1.375rem; /* 22px mobile (28 - 2*border - 2*pad) */ --switch-thumb-translate: calc(var(--switch-track-width) - var(--switch-thumb-size) - 6px); /* 20px */ } diff --git a/src/layout/sidebar/chat-actions.tsx b/src/layout/sidebar/chat-actions.tsx index 772cca6d1..ae00b61c2 100644 --- a/src/layout/sidebar/chat-actions.tsx +++ b/src/layout/sidebar/chat-actions.tsx @@ -8,6 +8,8 @@ import { cn } from '@/lib/utils' import { Flame, Loader2, Search } from 'lucide-react' import type { ChatActionsProps } from './types' +const actionButtonClass = 'size-[var(--touch-height-lg)] md:size-8 items-center justify-center cursor-pointer' + export const ChatActions = ({ isCollapsed, debouncedSearchQuery, @@ -30,25 +32,25 @@ export const ChatActions = ({ onClick={(e) => onSearchClick(e)} aria-label="Search chats" className={cn( - 'w-fit pr-0 pl-0 aspect-square items-center justify-center cursor-pointer', + actionButtonClass, showSearch && 'bg-sidebar-accent', debouncedSearchQuery && 'bg-primary/15 text-primary hover:bg-primary/25 hover:text-primary', )} > - + deleteAllChatsDialogRef.current?.open()} aria-label="Clear all chats" - className="w-fit pr-0 pl-0 aspect-square items-center justify-center cursor-pointer" + className={actionButtonClass} disabled={deleteAllChatsMutation.isPending} > {deleteAllChatsMutation.isPending ? ( - + ) : ( - + )} diff --git a/src/layout/sidebar/chat-list.tsx b/src/layout/sidebar/chat-list.tsx index 18ba328c0..b15d0c9e9 100644 --- a/src/layout/sidebar/chat-list.tsx +++ b/src/layout/sidebar/chat-list.tsx @@ -14,6 +14,7 @@ import { } from '@/components/ui/sidebar' import { cn } from '@/lib/utils' import { Flame, Loader2, Search } from 'lucide-react' +import { useEffect, useRef } from 'react' import { ChatActions } from './chat-actions' import { ChatListItem } from './chat-list-item' import { RailDivider } from './rail-divider' @@ -33,15 +34,61 @@ export const ChatList = ({ searchQuery, showSearch, searchInputRef, + hasContentAbove, + mobileNavToggle, onChatClick, onRename, onSearchClick, onSearchQueryChange, + onContentAboveChange, + onContentBelowChange, }: ChatListProps) => { + const scrollContainerRef = useRef(null) + + useEffect(() => { + const scrollContainer = scrollContainerRef.current + if (!scrollContainer) { + return + } + + const updateScrollShadows = () => { + const remainingScroll = scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop + onContentAboveChange(scrollContainer.scrollTop > 1) + onContentBelowChange(remainingScroll > 1) + } + + updateScrollShadows() + scrollContainer.addEventListener('scroll', updateScrollShadows, { passive: true }) + window.addEventListener('resize', updateScrollShadows) + + return () => { + scrollContainer.removeEventListener('scroll', updateScrollShadows) + window.removeEventListener('resize', updateScrollShadows) + } + }, [chatThreads.length, debouncedSearchQuery, onContentAboveChange, onContentBelowChange, showSearch]) + return ( <> - - {!isCollapsed && (chatThreads.length > 0 || debouncedSearchQuery) && ( + + {isMobile && ( +
+ {mobileNavToggle} + {(chatThreads.length > 0 || debouncedSearchQuery) && ( + + )} +
+ )} + {isMobile && !isCollapsed && (chatThreads.length > 0 || debouncedSearchQuery) && ( + Recent Chats + )} + {!isMobile && !isCollapsed && (chatThreads.length > 0 || debouncedSearchQuery) && (
Recent Chats onSearchQueryChange(e.target.value)} />
- + {isCollapsed && (chatThreads.length > 0 || debouncedSearchQuery) && ( <> diff --git a/src/layout/sidebar/chat-sidebar.tsx b/src/layout/sidebar/chat-sidebar.tsx index 9dec43aa9..f74efb969 100644 --- a/src/layout/sidebar/chat-sidebar.tsx +++ b/src/layout/sidebar/chat-sidebar.tsx @@ -17,7 +17,7 @@ import { import type { DeleteAllChatsMutationType, DeleteChatMutationType } from '@/layout/sidebar/types' import { cn } from '@/lib/utils' import { CheckSquare, MessageCirclePlus } from 'lucide-react' -import type { MouseEvent, RefObject } from 'react' +import { type MouseEvent, type RefObject, useState } from 'react' import { useLocation } from 'react-router' import { ChatList } from './chat-list' import { SidebarNavToggle } from './nav-toggle' @@ -76,48 +76,56 @@ export const ChatSidebarContent = ({ }: ChatSidebarContentProps) => { const { toggleSidebar } = useSidebar() const location = useLocation() + const [hasContentAbove, setHasContentAbove] = useState(false) + const [hasContentBelow, setHasContentBelow] = useState(false) return ( - + } /> - {/* Collapsed: pb-0 so SidebarContent's gap-2 alone spaces the divider - below; pt-2 gives the nav toggle the same 8px above as the rail's - p-2 leaves on its sides. */} - - - {isCollapsed && } - - - - - New Chat - - - {showTasks && ( - - - - Tasks - - + {(!isMobile || showTasks) && ( + + {/* Collapsed: pb-0 so SidebarContent's gap-2 alone spaces the divider + below; pt-2 gives the nav toggle the same 8px above as the rail's + p-2 leaves on its sides. */} + + {isCollapsed && ( + )} - - - + + {!isMobile && ( + + + + New Chat + + + )} + {showTasks && ( + + + + Tasks + + + )} + + +
+ )} {isCollapsed && chatThreads.length > 0 && } } onChatClick={onChatClick} onRename={onRename} onSearchClick={onSearchClick} onSearchQueryChange={onSearchQueryChange} + onContentAboveChange={setHasContentAbove} + onContentBelowChange={setHasContentBelow} /> - } - /> + ) } diff --git a/src/layout/sidebar/nav-toggle.tsx b/src/layout/sidebar/nav-toggle.tsx index 35c362796..2f7263f36 100644 --- a/src/layout/sidebar/nav-toggle.tsx +++ b/src/layout/sidebar/nav-toggle.tsx @@ -103,13 +103,13 @@ export const SidebarNavToggle = ({ activeSection, onSectionChange, vertical }: S return ( diff --git a/src/layout/sidebar/settings-sidebar.tsx b/src/layout/sidebar/settings-sidebar.tsx index 316cd946c..cab1ccd6b 100644 --- a/src/layout/sidebar/settings-sidebar.tsx +++ b/src/layout/sidebar/settings-sidebar.tsx @@ -63,7 +63,7 @@ export const SettingsSidebarContent = ({ onSectionChange, onSettingsNavigate, }: SettingsSidebarContentProps) => { - const { toggleSidebar } = useSidebar() + const { isMobile, toggleSidebar } = useSidebar() const location = useLocation() const isItemActive = ({ path, matchPrefix }: NavItem) => @@ -96,6 +96,11 @@ export const SettingsSidebarContent = ({ their dividers, so the groups' own vertical padding would double it. The last group keeps its bottom padding against the footer. */} + {isMobile && index === 0 && ( +
+ +
+ )} {group.label} @@ -120,7 +125,7 @@ export const SettingsSidebarContent = ({
- } /> + ) } diff --git a/src/layout/sidebar/sidebar-header.tsx b/src/layout/sidebar/sidebar-header.tsx index b894c6739..6c27e2635 100644 --- a/src/layout/sidebar/sidebar-header.tsx +++ b/src/layout/sidebar/sidebar-header.tsx @@ -20,7 +20,7 @@ import type { ReactNode } from 'react' type SidebarHeaderProps = { onToggle: () => void /** Chats/Settings pill, right-aligned in the desktop header while expanded. - * (Mobile renders no header; its pill lives in the footer instead.) */ + * Mobile renders the pill in the sidebar content instead. */ navToggle?: ReactNode } @@ -92,8 +92,7 @@ export const SidebarHeader = ({ onToggle, navToggle }: SidebarHeaderProps) => { )} {showMobileChromeSpacer &&
} {/* Mobile renders no header strip at all: the overlay sidebar closes by - tapping outside (no toggle) and the theme/sync/account controls live - in the footer row, so the list gets the vertical space instead. */} + tapping outside, and its section toggle lives in the content. */} {!showChromeStrip && !isMobile && (
{/* Desktop web right-aligns the nav pill opposite the toggle, diff --git a/src/layout/sidebar/types.ts b/src/layout/sidebar/types.ts index f8bed1129..0853f7aea 100644 --- a/src/layout/sidebar/types.ts +++ b/src/layout/sidebar/types.ts @@ -5,7 +5,7 @@ import type { DeleteAllChatsDialogRef } from '@/components/delete-all-chats-dialog' import type { DeleteChatDialogRef } from '@/components/delete-chat-dialog' import type { UseMutationResult } from '@tanstack/react-query' -import type { MouseEvent, RefObject } from 'react' +import type { MouseEvent, ReactNode, RefObject } from 'react' /** Top-level sidebar sections switchable via the nav toggle. */ export type SidebarSection = 'chats' | 'settings' @@ -44,10 +44,14 @@ export type ChatListProps = { searchQuery: string showSearch: boolean searchInputRef: RefObject + hasContentAbove: boolean + mobileNavToggle: ReactNode onChatClick: (threadId: string) => void onRename: (threadId: string, title: string) => void onSearchClick: (e?: MouseEvent) => void onSearchQueryChange: (value: string) => void + onContentAboveChange: (hasContentAbove: boolean) => void + onContentBelowChange: (hasContentBelow: boolean) => void } export type ChatListItemProps = { diff --git a/src/settings/models/index.tsx b/src/settings/models/index.tsx index 34a7f2430..6900b1eef 100644 --- a/src/settings/models/index.tsx +++ b/src/settings/models/index.tsx @@ -1155,11 +1155,11 @@ export default function ModelsPage() { const isSystemModel = model.isSystem === 1 return ( - - + +
-
-
+
+
{getModelInitial(model)}
@@ -1231,8 +1231,8 @@ export default function ModelsPage() {
{isEnabled && model.url && ( - -
+ +
URL {model.url} From b00a47846f6722e4ce6bd9e23c2f5ed5992613c0 Mon Sep 17 00:00:00 2001 From: Chris Roth Date: Wed, 22 Jul 2026 18:39:55 -0400 Subject: [PATCH 02/14] feat(design): unify mobile modal experiences - centralize full-screen mobile modal chrome, actions, and field surfaces - standardize responsive form actions, validation, and disabled states - refine mobile skill editing, search controls, and detail interactions Co-authored-by: Cursor --- src/components/available-tools.tsx | 2 +- src/components/detail-panel.test.tsx | 45 ++++ src/components/detail-panel.tsx | 107 ++++---- src/components/logout-modal.tsx | 5 +- .../onboarding/onboarding-action-buttons.tsx | 6 +- .../onboarding/onboarding-dialog.tsx | 16 +- src/components/pending-device-modal.tsx | 6 +- .../agents/add-custom-agent-dialog.tsx | 10 +- .../sync-setup/sync-setup-modal.tsx | 3 +- src/components/ui/button.tsx | 2 +- src/components/ui/combobox.tsx | 1 + .../ui/model-selector/model-selector.tsx | 1 + src/components/ui/prompt-input.tsx | 8 +- src/components/ui/responsive-modal.test.tsx | 41 ++++ src/components/ui/responsive-modal.tsx | 228 ++++++++++-------- src/layout/sidebar/chat-list.tsx | 2 +- src/layout/sidebar/rename-chat-dialog.tsx | 1 - src/routes/settings/agents/index.tsx | 2 +- src/settings/mcp-servers.tsx | 23 +- src/settings/models/index.test.tsx | 39 ++- src/settings/models/index.tsx | 56 +++-- src/skills/skill-detail.tsx | 5 +- src/skills/skill-form.test.tsx | 53 +++- src/skills/skill-form.tsx | 91 +++---- src/skills/skill-slug-field.tsx | 102 ++++++++ src/skills/skills-list.tsx | 6 +- src/skills/skills-view.test.tsx | 3 +- src/skills/skills-view.tsx | 9 +- src/skills/use-skill-form-state.ts | 1 + 29 files changed, 595 insertions(+), 279 deletions(-) create mode 100644 src/components/detail-panel.test.tsx create mode 100644 src/skills/skill-slug-field.tsx diff --git a/src/components/available-tools.tsx b/src/components/available-tools.tsx index da0d0a4c9..778fcd5eb 100644 --- a/src/components/available-tools.tsx +++ b/src/components/available-tools.tsx @@ -48,7 +48,7 @@ export const AvailableTools: FC = ({ tools, className }) =>
- +
Available Tools
diff --git a/src/components/detail-panel.test.tsx b/src/components/detail-panel.test.tsx new file mode 100644 index 000000000..17973633d --- /dev/null +++ b/src/components/detail-panel.test.tsx @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, mock } from 'bun:test' + +import { DetailPanel, DetailPanelSurface } from './detail-panel' + +describe('DetailPanelSurface', () => { + afterEach(() => { + cleanup() + }) + + it('uses the shared responsive modal on mobile', () => { + const onClose = mock() + + render( + +

Detail body

+
, + ) + + expect(document.querySelector('[data-slot="responsive-modal-content"]')).toBeInTheDocument() + expect(screen.getByText('Detail body')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('keeps the desktop detail header outside the modal shell', () => { + const onClose = mock() + + render( + + +

Detail body

+
+
, + ) + + expect(document.querySelector('[data-slot="responsive-modal-content"]')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Close details' })).toBeInTheDocument() + }) +}) diff --git a/src/components/detail-panel.tsx b/src/components/detail-panel.tsx index f6d5cfaa8..d0c229483 100644 --- a/src/components/detail-panel.tsx +++ b/src/components/detail-panel.tsx @@ -2,12 +2,20 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { AnimatePresence, m } from 'framer-motion' import { X } from 'lucide-react' import type { ReactNode } from 'react' import { SlideInPanel } from '@/components/slide-in-panel' import { Button, mutedIconButtonClass } from '@/components/ui/button' +import { Dialog } from '@/components/ui/dialog' +import { + ResponsiveModalContentComposable, + ResponsiveModalDescription, + ResponsiveModalActions, + ResponsiveModalHeader, + ResponsiveModalTitle, + useResponsiveModalContext, +} from '@/components/ui/responsive-modal' /** * Shared anatomy for the slide-in detail panels (skills, agents, CLI): one @@ -44,43 +52,54 @@ type DetailPanelProps = { * surface card, on mobile inside the full-screen overlay — so content lies * flat on the surface with hairline dividers instead of nested cards. */ -export const DetailPanel = ({ icon, title, subtitle, actions, onClose, children }: DetailPanelProps) => ( -
- {/* Mobile keeps the list's title-row height (shared page chrome); the - desktop card gets a taller header so the title has room to breathe. */} -
-
- {icon} -
-

{title}

- {subtitle && {subtitle}} -
-
- {/* Desktop: pin the actions to the card's top-right corner, 8px from - both edges (right: 24px padding − 16px), independent of the taller - header so the X stays equidistant from top and right. */} -
- {actions} - -
-
+export const DetailPanel = ({ icon, title, subtitle, actions, onClose, children }: DetailPanelProps) => { + const { isMobile } = useResponsiveModalContext() - {/* The whole body scrolls as one area. */} -
{children}
-
-) + return ( +
+ {isMobile ? ( + <> + + {title} + {subtitle && {subtitle}} + + {actions && {actions}} + + ) : ( +
+
+ {icon} +
+

{title}

+ {subtitle && {subtitle}} +
+
+ {/* Pin the actions to the card's top-right corner, 8px from both + edges (right: 24px padding − 16px). */} +
+ {actions} + +
+
+ )} + +
{children}
+
+ ) +} type DetailPanelSurfaceProps = { open: boolean isMobile: boolean + onClose: () => void children: ReactNode } @@ -91,9 +110,10 @@ type DetailPanelSurfaceProps = { * page by the app's soft glow shadow plus a faint border — bg-sidebar * (near-white in light mode) like the chat composer, bottom padding floating * the card off the window edge, right edge flush and square with only the - * left corners rounded. Mobile: a full-screen spring slide-over. + * left corners rounded. Mobile: a full-screen modal using the same fade/scale + * transition as the app's other responsive modals. */ -export const DetailPanelSurface = ({ open, isMobile, children }: DetailPanelSurfaceProps) => { +export const DetailPanelSurface = ({ open, isMobile, onClose, children }: DetailPanelSurfaceProps) => { if (!isMobile) { return ( @@ -106,19 +126,8 @@ export const DetailPanelSurface = ({ open, isMobile, children }: DetailPanelSurf ) } return ( - - {open && ( - - {children} - - )} - + !nextOpen && onClose()}> + {children} + ) } diff --git a/src/components/logout-modal.tsx b/src/components/logout-modal.tsx index d110974c3..d520488e3 100644 --- a/src/components/logout-modal.tsx +++ b/src/components/logout-modal.tsx @@ -8,6 +8,7 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' import { ResponsiveModal, + ResponsiveModalCancel, ResponsiveModalContent, ResponsiveModalDescription, ResponsiveModalFooter, @@ -94,9 +95,7 @@ export const LogoutModal = ({ open, onOpenChange, clearLocalData = defaultClearL - + handleOpenChange(false)} disabled={isLoggingOut} /> diff --git a/src/components/onboarding/onboarding-dialog.tsx b/src/components/onboarding/onboarding-dialog.tsx index 22313091a..eccc6c3c7 100644 --- a/src/components/onboarding/onboarding-dialog.tsx +++ b/src/components/onboarding/onboarding-dialog.tsx @@ -3,7 +3,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { useEffect, useState } from 'react' -import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import { Dialog, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import { ResponsiveModalContentComposable } from '@/components/ui/responsive-modal' import { useDatabase } from '@/contexts' import { deleteIntegrationCredentials } from '@/dal' import type { OAuthProvider } from '@/lib/auth' @@ -102,21 +103,18 @@ export const OnboardingDialog = () => { return ( - Onboarding Wizard Complete the setup process to get started with Thunderbolt
@@ -171,7 +169,7 @@ export const OnboardingDialog = () => { />
-
+
) } diff --git a/src/components/pending-device-modal.tsx b/src/components/pending-device-modal.tsx index 9f7e4557a..0f0e10175 100644 --- a/src/components/pending-device-modal.tsx +++ b/src/components/pending-device-modal.tsx @@ -8,7 +8,7 @@ import { Smartphone } from 'lucide-react' import { ApproveDeviceDialog } from '@/components/approve-device-dialog' import { RevokeDeviceDialog } from '@/components/revoke-device-dialog' import { Button } from '@/components/ui/button' -import { ResponsiveModal, ResponsiveModalContent } from '@/components/ui/responsive-modal' +import { ResponsiveModal, ResponsiveModalContent, ResponsiveModalTitle } from '@/components/ui/responsive-modal' import { IconCircle } from '@/components/onboarding/icon-circle' import { useApproveDevice } from '@/hooks/use-approve-device' import { useDenyDevice } from '@/hooks/use-deny-device' @@ -58,7 +58,9 @@ export const PendingDeviceModal = () => { -

New device waiting

+ + New device waiting +

A new device is requesting access to your encrypted data.

{pendingDeviceToNotify && ( diff --git a/src/components/settings/agents/add-custom-agent-dialog.tsx b/src/components/settings/agents/add-custom-agent-dialog.tsx index f2ce5b053..1b92efe92 100644 --- a/src/components/settings/agents/add-custom-agent-dialog.tsx +++ b/src/components/settings/agents/add-custom-agent-dialog.tsx @@ -8,8 +8,10 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { + ResponsiveModalCancel, ResponsiveModalContentComposable, ResponsiveModalDescription, + ResponsiveModalFooter, ResponsiveModalHeader, ResponsiveModalTitle, } from '@/components/ui/responsive-modal' @@ -282,19 +284,17 @@ export const AddCustomAgentDialog = ({

)}
-
+ {state.submitError && (

{state.submitError}

)} - + handleOpenChange(false)} /> -
+ ) diff --git a/src/components/sync-setup/sync-setup-modal.tsx b/src/components/sync-setup/sync-setup-modal.tsx index 53bbdf5f7..f4f36506c 100644 --- a/src/components/sync-setup/sync-setup-modal.tsx +++ b/src/components/sync-setup/sync-setup-modal.tsx @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { ResponsiveModal, ResponsiveModalContent } from '@/components/ui/responsive-modal' +import { ResponsiveModal, ResponsiveModalContent, ResponsiveModalTitle } from '@/components/ui/responsive-modal' import { Button } from '@/components/ui/button' import { useSyncSetup } from '@/hooks/use-sync-setup' import { useApprovalPolling } from '@/hooks/use-approval-polling' @@ -139,6 +139,7 @@ export const SyncSetupModal = ({ open, onOpenChange, onComplete }: SyncSetupModa } }} > + Set up encrypted sync {setup.step === 'recovery-key-entry' && ( ) // ============================================================================= @@ -215,41 +273,15 @@ export const ResponsiveModalContentComposable = ({ showCloseButton = true, ...props }: ResponsiveModalContentComposableProps) => { - const { isMobile } = useIsMobile() - return ( - - - - {children} - {showCloseButton && ( - - - Close - - )} - - + + {children} + ) } diff --git a/src/layout/sidebar/chat-list.tsx b/src/layout/sidebar/chat-list.tsx index b15d0c9e9..348251e76 100644 --- a/src/layout/sidebar/chat-list.tsx +++ b/src/layout/sidebar/chat-list.tsx @@ -115,7 +115,7 @@ export const ChatList = ({ onSearchQueryChange(e.target.value)} diff --git a/src/layout/sidebar/rename-chat-dialog.tsx b/src/layout/sidebar/rename-chat-dialog.tsx index 36a9ae64e..9a85a1185 100644 --- a/src/layout/sidebar/rename-chat-dialog.tsx +++ b/src/layout/sidebar/rename-chat-dialog.tsx @@ -31,7 +31,6 @@ const RenameChatForm = ({ title, onOpenChange, onRename }: Omit { e.preventDefault() // Deferred a frame: this dialog opens from a dropdown-menu item, and diff --git a/src/routes/settings/agents/index.tsx b/src/routes/settings/agents/index.tsx index 1378a1e7d..8971c66ec 100644 --- a/src/routes/settings/agents/index.tsx +++ b/src/routes/settings/agents/index.tsx @@ -131,7 +131,7 @@ export default function AgentsSettingsPage() {
- + {detailPanel} diff --git a/src/settings/mcp-servers.tsx b/src/settings/mcp-servers.tsx index 72cceab5a..40504151e 100644 --- a/src/settings/mcp-servers.tsx +++ b/src/settings/mcp-servers.tsx @@ -12,8 +12,10 @@ import { Label } from '@/components/ui/label' import { PageHeader } from '@/components/ui/page-header' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { + ResponsiveModalCancel, ResponsiveModalContentComposable, ResponsiveModalDescription, + ResponsiveModalFooter, ResponsiveModalHeader, ResponsiveModalTitle, } from '@/components/ui/responsive-modal' @@ -731,7 +733,7 @@ export default function McpServersPage({ deps = {} }: { deps?: McpServersPageDep - + {dialogTitle} {dialogDescription} @@ -760,10 +762,16 @@ export default function McpServersPage({ deps = {} }: { deps?: McpServersPageDep > {/* rounded-lg to match the Input fields below (same treatment as the preferences ThemeToggleGroup). */} - + Simple - + Advanced (JSON) @@ -911,16 +919,15 @@ export default function McpServersPage({ deps = {} }: { deps?: McpServersPageDep )}
-
- + {form.editingServerId ? (
+ diff --git a/src/settings/models/index.test.tsx b/src/settings/models/index.test.tsx index 9155ea10b..76e11bad0 100644 --- a/src/settings/models/index.test.tsx +++ b/src/settings/models/index.test.tsx @@ -11,7 +11,7 @@ import '@testing-library/jest-dom' import { act, cleanup, fireEvent, screen, within } from '@testing-library/react' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test' import { v7 as uuidv7 } from 'uuid' -import ModelsPage, { systemModelMenuMessage } from './index' +import ModelsPage, { shouldDisableAddModel, systemModelMenuMessage } from './index' describe('ModelsPage reactivity', () => { beforeAll(async () => { @@ -68,6 +68,43 @@ describe('ModelsPage reactivity', () => { }) }) +describe('add model form', () => { + beforeAll(async () => { + await setupTestDatabase() + }) + + afterAll(async () => { + await teardownTestDatabase() + }) + + beforeEach(async () => { + await resetTestDatabase() + }) + + afterEach(() => { + cleanup() + }) + + it('disables Add Model while required fields are empty', async () => { + renderWithReactivity(, { tables: ['models'] }) + + fireEvent.click(screen.getByRole('button', { name: 'Add model' })) + await waitForElement(() => screen.queryByRole('heading', { name: 'Add Model' })) + + expect(screen.getByRole('button', { name: 'Add Model' })).toBeDisabled() + }) +}) + +describe('shouldDisableAddModel', () => { + it('enables submission only when validation and connection gates pass', () => { + expect(shouldDisableAddModel(false, true, false, false)).toBe(false) + expect(shouldDisableAddModel(false, true, true, true)).toBe(false) + expect(shouldDisableAddModel(false, false, false, false)).toBe(true) + expect(shouldDisableAddModel(false, true, true, false)).toBe(true) + expect(shouldDisableAddModel(true, true, false, false)).toBe(true) + }) +}) + describe('model card action menu', () => { beforeAll(async () => { await setupTestDatabase() diff --git a/src/settings/models/index.tsx b/src/settings/models/index.tsx index 6900b1eef..8bb571b63 100644 --- a/src/settings/models/index.tsx +++ b/src/settings/models/index.tsx @@ -24,8 +24,10 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from ' import { Input } from '@/components/ui/input' import { PageHeader } from '@/components/ui/page-header' import { + ResponsiveModalCancel, ResponsiveModalContentComposable, ResponsiveModalDescription, + ResponsiveModalFooter, ResponsiveModalHeader, ResponsiveModalTitle, } from '@/components/ui/responsive-modal' @@ -149,6 +151,14 @@ const modelReducer = (state: ModelState, action: ModelAction): ModelState => { */ const providerRequiresConnectionTest = (provider: Model['provider']) => provider !== 'thunderbolt' +/** Determines whether the add-model form has completed every submission gate. */ +export const shouldDisableAddModel = ( + isPending: boolean, + isFormValid: boolean, + requiresConnectionTest: boolean, + isConnectionSuccessful: boolean, +) => isPending || !isFormValid || (requiresConnectionTest && !isConnectionSuccessful) + /** * Providers that need an API key to authenticate the test round-trip. Custom * (OpenAI-compatible) endpoints may or may not need one, so the key is treated @@ -348,7 +358,7 @@ const EditModelForm = ({ return (
- + -
- + + -
+ ) @@ -558,6 +566,7 @@ export default function ModelsPage() { const form = useForm({ resolver: zodResolver(formSchema), + mode: 'onChange', defaultValues: { provider: 'thunderbolt', name: '', @@ -800,21 +809,21 @@ export default function ModelsPage() { dispatch({ type: 'SELECT_MODEL', modelId: modelId }) if (modelId === 'custom') { - form.setValue('model', '') + form.setValue('model', '', { shouldValidate: true }) form.setValue('customModel', '') - form.setValue('name', '') + form.setValue('name', '', { shouldValidate: true }) } else { - form.setValue('model', modelId) + form.setValue('model', modelId, { shouldValidate: true }) form.setValue('customModel', '') const model = allAvailableModels.find((m) => m.id === modelId) if (model?.name) { - form.setValue('name', model.name) + form.setValue('name', model.name, { shouldValidate: true }) } else { // Generate a name locally const generatedName = generateModelName(modelId) - form.setValue('name', generatedName) + form.setValue('name', generatedName, { shouldValidate: true }) } } } @@ -840,6 +849,7 @@ export default function ModelsPage() { shouldDirty: false, }) form.setValue('apiKey', '', { shouldValidate: false, shouldDirty: false }) + void form.trigger() // Fetch models if we have the necessary credentials if (['thunderbolt', 'tinfoil', 'anthropic'].includes(currentProvider)) { @@ -940,13 +950,13 @@ export default function ModelsPage() { - + Add Model Add a new AI model
- + { field.onChange(e) - form.setValue('model', e.target.value) + form.setValue('model', e.target.value, { shouldValidate: true }) }} /> @@ -1129,20 +1139,20 @@ export default function ModelsPage() { error={connectionError} /> -
- + + handleDialogOpenChange(false)} /> -
+
diff --git a/src/skills/skill-detail.tsx b/src/skills/skill-detail.tsx index 5fa314c12..add7a83cf 100644 --- a/src/skills/skill-detail.tsx +++ b/src/skills/skill-detail.tsx @@ -7,6 +7,7 @@ import { Info, MoreVertical, SquarePen, Trash2 } from 'lucide-react' import { DetailDivider, DetailPanel, DetailSectionTitle } from '@/components/detail-panel' import { Button, mutedIconButtonClass } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { useResponsiveModalContext } from '@/components/ui/responsive-modal' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' /** @@ -32,6 +33,8 @@ export const SkillDetail = ({ * panel or the mobile overlay. */ onClose: () => void }) => { + const { isMobile } = useResponsiveModalContext() + const actionsMenu = ( @@ -39,7 +42,7 @@ export const SkillDetail = ({ - + Edit diff --git a/src/skills/skill-form.test.tsx b/src/skills/skill-form.test.tsx index 7f980c3a0..032cb9400 100644 --- a/src/skills/skill-form.test.tsx +++ b/src/skills/skill-form.test.tsx @@ -52,12 +52,59 @@ describe('SkillForm slug auto-generation', () => { // Renaming must not touch the existing slug (it would break `/tokens`). fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Rebranded Brief' } }) - expect(screen.getByLabelText('Slug')).toHaveValue('daily-brief') + expect(screen.getByText('/daily-brief')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Edit slug' })) + const slugInput = screen.getByLabelText('Slug') + expect(slugInput).toHaveValue('daily-brief') // Clearing the slug must NOT re-attach auto-generation in edit mode — // the user is retyping it deliberately, not asking for a rename cascade. - fireEvent.change(screen.getByLabelText('Slug'), { target: { value: '' } }) + fireEvent.change(slugInput, { target: { value: '' } }) fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Another Name' } }) - expect(screen.getByLabelText('Slug')).toHaveValue('') + expect(slugInput).toHaveValue('') + }) +}) + +describe('SkillForm responsive layout', () => { + it('keeps the Instructions field usable before the mobile modal scrolls', () => { + render() + + expect(screen.getByLabelText('Instructions')).toHaveClass('min-h-48', 'md:min-h-0') + }) +}) + +describe('SkillForm edit submission', () => { + it('enables Save only while the form differs from its initial values', () => { + render() + + const saveButton = screen.getByRole('button', { name: 'Save' }) + expect(saveButton).toBeDisabled() + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Updated Brief' } }) + expect(saveButton).toBeEnabled() + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: editValues.label } }) + expect(saveButton).toBeDisabled() + }) +}) + +describe('SkillForm slug customization', () => { + it('toggles between the slug text and editable input', () => { + render() + + expect(screen.getByText('/daily-brief')).toBeInTheDocument() + expect(screen.queryByRole('textbox', { name: 'Slug' })).not.toBeInTheDocument() + + const editButton = screen.getByRole('button', { name: 'Edit slug' }) + expect(editButton).toHaveClass('min-h-[var(--min-touch-height)]') + fireEvent.click(editButton) + + expect(screen.getByRole('textbox', { name: 'Slug' })).toHaveValue('daily-brief') + + fireEvent.click(screen.getByRole('button', { name: 'Finish editing slug' })) + + expect(screen.getByText('/daily-brief')).toBeInTheDocument() + expect(screen.queryByRole('textbox', { name: 'Slug' })).not.toBeInTheDocument() }) }) diff --git a/src/skills/skill-form.tsx b/src/skills/skill-form.tsx index c5b6ce5e3..62f3f922d 100644 --- a/src/skills/skill-form.tsx +++ b/src/skills/skill-form.tsx @@ -5,12 +5,17 @@ import { Info, X } from 'lucide-react' import { useEffect, useRef } from 'react' -import { Button, mutedIconButtonClass } from '@/components/ui/button' +import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { + ResponsiveModalHeader, + ResponsiveModalTitle, + useResponsiveModalContext, +} from '@/components/ui/responsive-modal' import { Textarea } from '@/components/ui/textarea' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { validateSkillName } from '@/dal' -import { cn } from '@/lib/utils' +import { SkillSlugField } from './skill-slug-field' import { useSkillFormState, type SkillFormMode, type SkillFormValues } from './use-skill-form-state' export type { SkillFormMode, SkillFormValues } @@ -51,11 +56,13 @@ export const SkillForm = ({ /** Generic save-failure message shown next to the submit button. */ submitError?: string | null }) => { + const { isMobile } = useResponsiveModalContext() const { label, slug, description, instruction, + isDirty, handleLabelChange, handleSlugChange, handleDescriptionChange, @@ -89,9 +96,10 @@ export const SkillForm = ({ instruction.trim() !== '' && localSlugError === null && !slugError + const canSave = canSubmit && (mode === 'create' || isDirty) const handleSubmit = () => { - if (!canSubmit) { + if (!canSave) { return } void onSubmit({ @@ -103,23 +111,32 @@ export const SkillForm = ({ } return ( - // No background of its own: inherits the desktop slide-in surface card - // or the mobile overlay's background. -
+ // No background of its own: inherits the desktop panel or mobile dialog surface. +
{/* Same corner placement as the detail panel's close button (8px from top and right); behaves exactly like Cancel, including the unsaved-changes guard. */} - -
-

{mode === 'edit' ? 'Edit skill' : 'Create skill'}

+ {!isMobile && ( + + )} +
+ {isMobile ? ( + + {mode === 'edit' ? 'Edit Skill' : 'Create Skill'} + + ) : ( +

+ {mode === 'edit' ? 'Edit Skill' : 'Create Skill'} +

+ )}
@@ -193,7 +180,7 @@ export const SkillForm = ({ />
-
+
@@ -202,7 +189,7 @@ export const SkillForm = ({ placeholder="What the assistant should do…" value={instruction} onChange={(e) => handleInstructionChange(e.target.value)} - className="min-h-0 flex-1 resize-none" + className="min-h-48 resize-y md:min-h-0 md:flex-1 md:resize-none" />
@@ -218,7 +205,7 @@ export const SkillForm = ({ - diff --git a/src/skills/skill-slug-field.tsx b/src/skills/skill-slug-field.tsx new file mode 100644 index 000000000..0fadc4c17 --- /dev/null +++ b/src/skills/skill-slug-field.tsx @@ -0,0 +1,102 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { AnimatePresence, m } from 'framer-motion' +import { Check, Pencil } from 'lucide-react' +import { useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import type { SkillFormMode } from './use-skill-form-state' + +type SkillSlugFieldProps = { + mode: SkillFormMode + slug: string + error?: string | null + onChange: (slug: string) => void +} + +/** Displays an existing skill slug as text until the user chooses to edit it. */ +export const SkillSlugField = ({ mode, slug, error, onChange }: SkillSlugFieldProps) => { + const [isEditingSlug, setIsEditingSlug] = useState(false) + const isCreateMode = mode === 'create' + + const input = ( +
+ + onChange(event.target.value)} + aria-invalid={error ? true : undefined} + className={cn( + 'pl-7 text-foreground md:h-7 md:rounded-md md:border-transparent md:bg-transparent md:pl-4 md:pr-2 md:!text-xs md:text-muted-foreground md:shadow-none', + 'md:hover:border-border md:focus-visible:border-border-strong md:focus-visible:text-foreground', + 'md:dark:bg-transparent md:dark:hover:bg-transparent', + )} + /> +
+ ) + + return ( + <> +
+ + {isCreateMode ? ( + input + ) : ( +
+ + {isEditingSlug ? ( + + {input} + + ) : ( + + /{slug} + + )} + + +
+ )} +
+ {error &&

{error}

} + + ) +} diff --git a/src/skills/skills-list.tsx b/src/skills/skills-list.tsx index b7b825e40..9a227f03f 100644 --- a/src/skills/skills-list.tsx +++ b/src/skills/skills-list.tsx @@ -66,11 +66,7 @@ export const SkillsList = ({ - + {/* LayoutGroup links the Enabled and Disabled
    s so a row's diff --git a/src/skills/skills-view.test.tsx b/src/skills/skills-view.test.tsx index ed3a8d020..d4e901e3c 100644 --- a/src/skills/skills-view.test.tsx +++ b/src/skills/skills-view.test.tsx @@ -236,7 +236,7 @@ describe('SkillsView state machine', () => { wrapper: wrapperWithNavState({ startEditSkill: created.id }), }) - await waitForElement(() => screen.queryByText('Edit skill')) + await waitForElement(() => screen.queryByText('Edit Skill')) const nameInput = screen.getByRole('textbox', { name: 'Name' }) as HTMLInputElement expect(nameInput.value).toBe('Daily Brief') expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument() @@ -323,6 +323,7 @@ describe('SkillsView state machine', () => { // Confirming lands in a fresh edit form on the target skill. const editName = screen.getByRole('textbox', { name: 'Name' }) as HTMLInputElement expect(editName.value).toBe('Beta') + fireEvent.click(screen.getByRole('button', { name: 'Edit slug' })) const editSlug = screen.getByRole('textbox', { name: 'Slug' }) as HTMLInputElement expect(editSlug.value).toBe('beta') expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument() diff --git a/src/skills/skills-view.tsx b/src/skills/skills-view.tsx index 9105dd6ef..0f898b0cc 100644 --- a/src/skills/skills-view.tsx +++ b/src/skills/skills-view.tsx @@ -272,6 +272,13 @@ export const SkillsView = () => { const panel = renderPanel() const panelOpen = panelView === 'panel' && panel !== null + const closePanel = () => { + if (mode === 'detail') { + dispatch({ type: 'BACK_TO_LIST' }) + return + } + requestLeave({ type: 'cancel' }) + } return (
    @@ -287,7 +294,7 @@ export const SkillsView = () => { onDeleteSkill={onDelete} />
    - + {panel} {pendingDependents && ( diff --git a/src/skills/use-skill-form-state.ts b/src/skills/use-skill-form-state.ts index f140c575c..ba2492b2a 100644 --- a/src/skills/use-skill-form-state.ts +++ b/src/skills/use-skill-form-state.ts @@ -182,6 +182,7 @@ export const useSkillFormState = ({ slug: state.slug, description: state.description, instruction: state.instruction, + isDirty: isSkillFormDirty(state, initialState), handleLabelChange, handleSlugChange, handleDescriptionChange, From 5eedb7df490ea08e18f768bcd7ed535cefcaced5 Mon Sep 17 00:00:00 2001 From: Chris Roth Date: Wed, 22 Jul 2026 22:49:18 -0400 Subject: [PATCH 03/14] feat(design): complete settings and responsive redesign Unify connection, model, skill, and agent management around the redesigned responsive surfaces while refining mobile navigation and default reconciliation. Co-authored-by: Cursor --- shared/defaults/models.test.ts | 2 +- shared/defaults/models.ts | 6 +- src/app.tsx | 9 +- src/assets/openai.svg | 1 + src/assets/openrouter.svg | 3 + src/assets/tinfoil.svg | 3 + src/components/chat/chat-prompt-input.tsx | 6 +- src/components/chat/chat-skills-bar.tsx | 2 +- src/components/chat/chat-ui.tsx | 3 +- src/components/chat/mobile-user-message.tsx | 7 +- src/components/detail-panel.test.tsx | 27 +- src/components/detail-panel.tsx | 49 +- src/components/oauth-callback.test.tsx | 13 +- src/components/oauth-callback.tsx | 4 +- .../agents/add-custom-agent-dialog.tsx | 220 ++- .../settings/agents/agent-detail.tsx | 4 +- src/components/sidebar-footer.tsx | 4 +- src/components/slide-in-panel.tsx | 19 +- src/components/ui/alert-dialog.tsx | 2 +- src/components/ui/button.tsx | 12 +- src/components/ui/combobox.test.tsx | 12 +- src/components/ui/combobox.tsx | 10 +- src/components/ui/dialog.tsx | 8 +- src/components/ui/form.tsx | 2 +- .../ui/mobile-blur-backdrop.test.tsx | 23 + src/components/ui/mobile-blur-backdrop.tsx | 7 +- src/components/ui/mobile-sidebar.test.tsx | 6 +- src/components/ui/mobile-sidebar.tsx | 9 +- src/components/ui/page-search.test.tsx | 36 + src/components/ui/page-search.tsx | 5 +- src/components/ui/responsive-modal.test.tsx | 21 +- src/components/ui/responsive-modal.tsx | 21 +- src/components/ui/sheet.tsx | 2 +- src/components/ui/toggle.tsx | 2 +- src/hooks/use-deep-link-listener.test.ts | 36 +- src/hooks/use-deep-link-listener.ts | 8 +- src/index.css | 3 + src/layout/sidebar/chat-list-item.tsx | 8 +- src/layout/sidebar/chat-list.tsx | 46 +- src/layout/sidebar/chat-sidebar.tsx | 39 +- src/layout/sidebar/settings-sidebar.tsx | 5 +- src/layout/sidebar/types.ts | 1 + src/lib/reconcile-defaults.test.ts | 29 +- src/lib/reconcile-defaults.ts | 16 +- src/routes/settings/agents/index.tsx | 55 +- src/settings/connections/connections-list.tsx | 283 ++++ src/settings/connections/display.ts | 31 + .../index.test.tsx} | 189 ++- src/settings/connections/index.tsx | 893 ++++++++++++ .../connections/integration-detail.tsx | 94 ++ .../connections/mcp-server-detail.tsx | 197 +++ src/settings/connections/mcp-server-form.tsx | 358 +++++ src/settings/connections/types.ts | 21 + src/settings/integrations.tsx | 290 ---- src/settings/mcp-servers.tsx | 1204 ----------------- src/settings/models/index.test.tsx | 115 +- src/settings/models/index.tsx | 1137 +++++++++------- src/settings/skills.tsx | 2 +- src/skills/library-row.tsx | 50 +- src/skills/skill-detail.tsx | 7 +- src/skills/skill-form.test.tsx | 20 +- src/skills/skill-form.tsx | 71 +- src/skills/skill-slug-field.tsx | 102 -- src/skills/skills-list.tsx | 17 +- src/skills/skills-view.test.tsx | 1 - src/skills/skills-view.tsx | 69 +- src/tasks/index.tsx | 55 +- 67 files changed, 3398 insertions(+), 2614 deletions(-) create mode 100644 src/assets/openai.svg create mode 100644 src/assets/openrouter.svg create mode 100644 src/assets/tinfoil.svg create mode 100644 src/components/ui/mobile-blur-backdrop.test.tsx create mode 100644 src/components/ui/page-search.test.tsx create mode 100644 src/settings/connections/connections-list.tsx create mode 100644 src/settings/connections/display.ts rename src/settings/{mcp-servers.test.tsx => connections/index.test.tsx} (80%) create mode 100644 src/settings/connections/index.tsx create mode 100644 src/settings/connections/integration-detail.tsx create mode 100644 src/settings/connections/mcp-server-detail.tsx create mode 100644 src/settings/connections/mcp-server-form.tsx create mode 100644 src/settings/connections/types.ts delete mode 100644 src/settings/integrations.tsx delete mode 100644 src/settings/mcp-servers.tsx delete mode 100644 src/skills/skill-slug-field.tsx diff --git a/shared/defaults/models.test.ts b/shared/defaults/models.test.ts index 484e13eaa..6a3f675ab 100644 --- a/shared/defaults/models.test.ts +++ b/shared/defaults/models.test.ts @@ -21,7 +21,7 @@ const computeSnapshotHash = () => defaultModels.map((model, index) => `${index}:${model.id}:${hashModel(model)}`).join('|') const expected = { - version: 2, + version: 3, hash: '0:019af08a-c27b-7074-8aac-95315d1ef3fd:-1vf2pk|1:019f227e-d640-727d-ba12-d51bd7d0a3d6:bvaax2|2:019e7580-2b0e-719c-a43f-d2b56e7f31b4:-g7x2jr', } diff --git a/shared/defaults/models.ts b/shared/defaults/models.ts index 35d0c9bc8..383b9a592 100644 --- a/shared/defaults/models.ts +++ b/shared/defaults/models.ts @@ -123,6 +123,8 @@ export const defaultModelDeepseekV4Flash: SharedModel = { export const defaultModelGlm52: SharedModel = { id: '019e7580-2b0e-719c-a43f-d2b56e7f31b4', name: 'GLM 5.2', + // `provider` is the internal transport. The UI presents system-managed + // Tinfoil models as Thunderbolt so infrastructure does not leak into branding. provider: 'tinfoil', model: 'glm-5-2', isSystem: 1, @@ -136,7 +138,7 @@ export const defaultModelGlm52: SharedModel = { url: null, defaultHash: null, vendor: 'zhipu', - description: 'Confidential chat via Tinfoil', + description: 'Confidential chat via Thunderbolt', userId: null, } @@ -167,4 +169,4 @@ export const defaultModels: ReadonlyArray = [ * The paired snapshot test in `models.test.ts` fails on any change to this * file's defaults without a matching version bump. */ -export const defaultModelsVersion = 2 +export const defaultModelsVersion = 3 diff --git a/src/app.tsx b/src/app.tsx index f89f27572..615858dd0 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -81,10 +81,9 @@ const Settings = lazy(() => import('@/settings/index')) const PreferencesSettingsPage = lazy(() => import('@/settings/preferences')) const ModelsPage = lazy(() => import('@/settings/models')) const DevicesSettingsPage = lazy(() => import('@/settings/devices')) -const McpServersPage = lazy(() => import('@/settings/mcp-servers')) +const ConnectionsPage = lazy(() => import('@/settings/connections')) const SkillsPage = lazy(() => import('@/settings/skills')) const AgentsSettingsPage = lazy(() => import('@/routes/settings/agents')) -const IntegrationsPage = lazy(() => import('@/settings/integrations')) // Lazily import SSO components so non-enterprise deployments don't pay // for the extra bundle size and attack surface. @@ -205,10 +204,12 @@ const AppRoutes = ({ initData }: { initData: InitData }) => { } /> } /> } /> - } /> + } /> + {/* Legacy routes — MCP servers and integrations merged into Connections. */} + } /> + } /> } /> } /> - } /> {import.meta.env.DEV && } />} diff --git a/src/assets/openai.svg b/src/assets/openai.svg new file mode 100644 index 000000000..b31e39897 --- /dev/null +++ b/src/assets/openai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/openrouter.svg b/src/assets/openrouter.svg new file mode 100644 index 000000000..452db8ebe --- /dev/null +++ b/src/assets/openrouter.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/tinfoil.svg b/src/assets/tinfoil.svg new file mode 100644 index 000000000..a3a418d12 --- /dev/null +++ b/src/assets/tinfoil.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/chat/chat-prompt-input.tsx b/src/components/chat/chat-prompt-input.tsx index 212792601..165296698 100644 --- a/src/components/chat/chat-prompt-input.tsx +++ b/src/components/chat/chat-prompt-input.tsx @@ -29,7 +29,7 @@ import { useChat as useChat_default } from '@ai-sdk/react' import { messageBookkeepingThrottleMs } from '@/chats/chat-throttle' import { useDraftInput } from '@/hooks/use-draft-input' import { AnimatePresence, m } from 'framer-motion' -import { AlertCircle, Loader2, Paperclip, Plus, X } from 'lucide-react' +import { AlertCircle, Loader2, Paperclip, Plug, Plus, X } from 'lucide-react' import { type ClipboardEvent, forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useLocation as useLocation_default, useNavigate as useNavigate_default } from 'react-router' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' @@ -568,6 +568,10 @@ export const ChatPromptInput = forwardRef Upload file + navigate('/settings/connections')} className="cursor-pointer"> + + Connections + {isConnecting ? ( diff --git a/src/components/chat/chat-skills-bar.tsx b/src/components/chat/chat-skills-bar.tsx index b176b509b..a1d3ba686 100644 --- a/src/components/chat/chat-skills-bar.tsx +++ b/src/components/chat/chat-skills-bar.tsx @@ -338,7 +338,7 @@ const MobileOverlay = ({ onDismiss }: { onDismiss: () => void }) => {
- {isMenuOpen &&
} + {isMenuOpen && ( +
+ )}
) } diff --git a/src/components/detail-panel.test.tsx b/src/components/detail-panel.test.tsx index 17973633d..de857d956 100644 --- a/src/components/detail-panel.test.tsx +++ b/src/components/detail-panel.test.tsx @@ -5,6 +5,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, mock } from 'bun:test' +import { ResponsiveModalFooter } from './ui/responsive-modal' import { DetailPanel, DetailPanelSurface } from './detail-panel' describe('DetailPanelSurface', () => { @@ -17,17 +18,36 @@ describe('DetailPanelSurface', () => { render( -

Detail body

+ +

Detail body

+ + + +
, ) expect(document.querySelector('[data-slot="responsive-modal-content"]')).toBeInTheDocument() expect(screen.getByText('Detail body')).toBeInTheDocument() + const scrollArea = screen.getByText('Detail body').parentElement + expect(scrollArea).toHaveClass('pt-6', 'md:pt-4', '[&_[data-slot=dialog-footer]]:sticky') fireEvent.click(screen.getByRole('button', { name: 'Close' })) expect(onClose).toHaveBeenCalledTimes(1) }) + it('adds more mobile spacing below a subtitle', () => { + render( + {}}> + {}}> +

Server status

+
+
, + ) + + expect(screen.getByText('Server status').parentElement).toHaveClass('pt-8', 'md:pt-4') + }) + it('keeps the desktop detail header outside the modal shell', () => { const onClose = mock() @@ -41,5 +61,10 @@ describe('DetailPanelSurface', () => { expect(document.querySelector('[data-slot="responsive-modal-content"]')).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Close details' })).toBeInTheDocument() + const surface = screen.getByText('Detail body').closest('.bg-sidebar') + expect(surface?.closest('aside')).toHaveClass('[filter:drop-shadow(var(--shadow-glow-strong))]') + expect(surface?.parentElement).toHaveClass('pb-12') + expect(surface?.parentElement).not.toHaveClass('pt-3') + expect(surface?.parentElement).not.toHaveClass('pl-4') }) }) diff --git a/src/components/detail-panel.tsx b/src/components/detail-panel.tsx index d0c229483..6897b4350 100644 --- a/src/components/detail-panel.tsx +++ b/src/components/detail-panel.tsx @@ -16,6 +16,7 @@ import { ResponsiveModalTitle, useResponsiveModalContext, } from '@/components/ui/responsive-modal' +import { cn } from '@/lib/utils' /** * Shared anatomy for the slide-in detail panels (skills, agents, CLI): one @@ -66,7 +67,9 @@ export const DetailPanel = ({ icon, title, subtitle, actions, onClose, children {actions && {actions}} ) : ( -
+ // mt-2.5 brings the icon tile's top gap to 24px ((64 − 36) / 2 + 10), + // matching the panel's md:px-6 left padding. +
{icon}
@@ -74,9 +77,11 @@ export const DetailPanel = ({ icon, title, subtitle, actions, onClose, children {subtitle && {subtitle}}
- {/* Pin the actions to the card's top-right corner, 8px from both - edges (right: 24px padding − 16px). */} -
+ {/* Pin the controls to the card's top-right corner, 8px from both + edges (right: 24px padding − 16px; top: 8px − the header's 10px + margin). Close sits outermost (the desktop convention); the + mobile shell instead splits them across the top corners. */} +
{actions}
)} -
{children}
+
+ {children} +
) } @@ -108,17 +122,26 @@ type DetailPanelSurfaceProps = { * right-side slide-in at a ~50/50 split with the list (half the viewport * minus half the sidebar), on one continuous surface card lifted off the * page by the app's soft glow shadow plus a faint border — bg-sidebar - * (near-white in light mode) like the chat composer, bottom padding floating - * the card off the window edge, right edge flush and square with only the - * left corners rounded. Mobile: a full-screen modal using the same fade/scale - * transition as the app's other responsive modals. + * (near-white in light mode) like the chat composer. The header inset above + * and bottom padding below float the card off the window edges by the same + * 48px; callers leave the outer + * flex row unclipped so the glow can extend beyond that inset naturally. Its + * right edge stays flush and square with only the left corners rounded. Mobile + * uses the same full-screen fade/scale modal as other responsive views. */ export const DetailPanelSurface = ({ open, isMobile, onClose, children }: DetailPanelSurfaceProps) => { if (!isMobile) { return ( - -
-
+ // The warm 6% glow is invisible on the dark background (same rationale + // as the .dark elevation overrides in index.css), so dark mode swaps in + // a slightly stronger black ink at the same blur radius. + +
+
{children}
diff --git a/src/components/oauth-callback.test.tsx b/src/components/oauth-callback.test.tsx index b1ea67af3..2bae2d13a 100644 --- a/src/components/oauth-callback.test.tsx +++ b/src/components/oauth-callback.test.tsx @@ -21,13 +21,14 @@ const PageProbe = ({ testId }: { testId: string }) => { return
{oauth?.error}
} +// Both the MCP and integrations flows land on the combined Connections page — +// which handler processes the payload there is decided by `isMcpOAuthCallback`. const renderCallback = () => render( } /> - } /> - } /> + } /> , ) @@ -72,16 +73,16 @@ describe('OAuthCallback routing', () => { await settleDeferredRedirect() - expect(screen.getByTestId('mcp-servers-page')).toHaveTextContent('User denied access') + expect(screen.getByTestId('connections-page')).toHaveTextContent('User denied access') }) - it('routes a description-only error to integrations when no MCP handshake is pending', async () => { + it('routes a description-only error to connections when no MCP handshake is pending', async () => { setCallbackUrl('?error_description=User+denied+access') renderCallback() await settleDeferredRedirect() - expect(screen.getByTestId('integrations-page')).toHaveTextContent('User denied access') + expect(screen.getByTestId('connections-page')).toHaveTextContent('User denied access') }) it('claims an error-only callback for the pending MCP handshake (existing behavior preserved)', async () => { @@ -91,6 +92,6 @@ describe('OAuthCallback routing', () => { await settleDeferredRedirect() - expect(screen.getByTestId('mcp-servers-page')).toHaveTextContent('access_denied') + expect(screen.getByTestId('connections-page')).toHaveTextContent('access_denied') }) }) diff --git a/src/components/oauth-callback.tsx b/src/components/oauth-callback.tsx index 50fc0b7f2..3a174ee49 100644 --- a/src/components/oauth-callback.tsx +++ b/src/components/oauth-callback.tsx @@ -66,7 +66,7 @@ export default function OAuthCallback() { // pending), independent of the shared return-context slot — so a // concurrent integrations flow can't misroute it to the wrong page. if (isMcpOAuthCallback({ code, state, error: oauthError })) { - navigate('/settings/mcp-servers', { state: { oauth: oauthPayload } }) + navigate('/settings/connections', { state: { oauth: oauthPayload } }) return } @@ -78,7 +78,7 @@ export default function OAuthCallback() { } else if (returnContext === 'onboarding') { navigate('/chats/new', { state: { oauth: oauthPayload } }) } else { - navigate('/settings/integrations', { state: { oauth: oauthPayload } }) + navigate('/settings/connections', { state: { oauth: oauthPayload } }) } }, 500) diff --git a/src/components/settings/agents/add-custom-agent-dialog.tsx b/src/components/settings/agents/add-custom-agent-dialog.tsx index 1b92efe92..f9019ddee 100644 --- a/src/components/settings/agents/add-custom-agent-dialog.tsx +++ b/src/components/settings/agents/add-custom-agent-dialog.tsx @@ -7,15 +7,7 @@ import { Check, Loader2, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { - ResponsiveModalCancel, - ResponsiveModalContentComposable, - ResponsiveModalDescription, - ResponsiveModalFooter, - ResponsiveModalHeader, - ResponsiveModalTitle, -} from '@/components/ui/responsive-modal' -import { Dialog } from '@/components/ui/dialog' +import { ResponsiveModalCancel, ResponsiveModalFooter } from '@/components/ui/responsive-modal' import { StatusCard } from '@/components/ui/status-card' import { testAcpConnection as defaultTestAcpConnection } from '@/acp' import { irohClientNodeId } from '@/acp/iroh/iroh-transport' @@ -188,114 +180,112 @@ export const AddCustomAgentDialog = ({ onOpenChange(false) } + if (!open) { + return null + } + + // Renders as plain panel content — the agents page hosts it inside the + // shared DetailPanelSurface (same aside idiom as the skills create form), + // which owns the "Add custom agent" header and close affordance. return ( - - - - Add custom agent - - Connect a remote agent that speaks the Agent Client Protocol. - - -
-
- - dispatch({ type: 'NAME_CHANGED', value: e.target.value })} - autoComplete="off" - /> -
-
- - dispatch({ type: 'URL_CHANGED', value: e.target.value })} - autoComplete="off" - autoCapitalize="none" - autoCorrect="off" - spellCheck={false} - /> -

- A WebSocket endpoint, or paste an iroh ticket from your bridge for a peer-to-peer connection (a bare - NodeId works only if the peer is discoverable). -

-
- {isIroh && } -
- - dispatch({ type: 'DESCRIPTION_CHANGED', value: e.target.value })} - autoComplete="off" - /> -
- {canTestConnection && ( - - )} - {state.connectionStatus === 'success' && ( - - - Connection successful! - - } - description="Successfully connected to the agent." - className="border-green-200/50 dark:border-green-500/20" - /> - )} - {state.connectionStatus === 'error' && ( - - - Connection failed - - } - description={state.connectionError || 'Could not connect to the agent.'} - className="bg-red-50/50 dark:bg-red-500/10 border-red-200/50 dark:border-red-500/20" - /> - )} - {urlError && ( -

- {urlError} -

- )} +
+

Connect a remote agent that speaks the Agent Client Protocol.

+
+
+ + dispatch({ type: 'NAME_CHANGED', value: e.target.value })} + autoComplete="off" + /> +
+
+ + dispatch({ type: 'URL_CHANGED', value: e.target.value })} + autoComplete="off" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + /> +

+ A WebSocket endpoint, or paste an iroh ticket from your bridge for a peer-to-peer connection (a bare NodeId + works only if the peer is discoverable). +

+
+ {isIroh && } +
+ + dispatch({ type: 'DESCRIPTION_CHANGED', value: e.target.value })} + autoComplete="off" + />
- - {state.submitError && ( -

- {state.submitError} -

- )} - handleOpenChange(false)} /> - -
- -
+ )} + {state.connectionStatus === 'success' && ( + + + Connection successful! + + } + description="Successfully connected to the agent." + /> + )} + {state.connectionStatus === 'error' && ( + + + Connection failed + + } + description={state.connectionError || 'Could not connect to the agent.'} + /> + )} + {urlError && ( +

+ {urlError} +

+ )} +
+ + {state.submitError && ( +

+ {state.submitError} +

+ )} + handleOpenChange(false)} /> + +
+
) } diff --git a/src/components/settings/agents/agent-detail.tsx b/src/components/settings/agents/agent-detail.tsx index a0fd85a8e..07bed2aef 100644 --- a/src/components/settings/agents/agent-detail.tsx +++ b/src/components/settings/agents/agent-detail.tsx @@ -217,7 +217,7 @@ const BuiltInBody = () => {
MCP servers {mcpServers.length} {mcpServers.length === 1 ? 'MCP server' : 'MCP servers'} @@ -226,7 +226,7 @@ const BuiltInBody = () => {
Integrations Manage integrations diff --git a/src/components/sidebar-footer.tsx b/src/components/sidebar-footer.tsx index d8aa135e0..8f6b79e9c 100644 --- a/src/components/sidebar-footer.tsx +++ b/src/components/sidebar-footer.tsx @@ -281,13 +281,13 @@ export const SidebarFooter = ({ className, hasContentBelow = false }: SidebarFoo {isDesktopCollapsed ? ( -
+
{isPending ? (
diff --git a/src/components/slide-in-panel.tsx b/src/components/slide-in-panel.tsx index 8b9b5af1c..1e597fd76 100644 --- a/src/components/slide-in-panel.tsx +++ b/src/components/slide-in-panel.tsx @@ -4,6 +4,8 @@ import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + // Linear's spring curve — fast start, smooth tail, no overshoot. const slideEasing = 'cubic-bezier(0.32, 0.72, 0, 1)' @@ -18,12 +20,25 @@ const slideEasing = 'cubic-bezier(0.32, 0.72, 0, 1)' * the inner content div reuses it as a stable width while the outer collapses * to 0, and a percentage would resolve against the collapsed parent. */ -export const SlideInPanel = ({ open, width, children }: { open: boolean; width: string; children: ReactNode }) => ( +export const SlideInPanel = ({ + open, + width, + className, + children, +}: { + open: boolean + width: string + className?: string + children: ReactNode +}) => (