mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 16:15:14 +00:00
mobile top nav
This commit is contained in:
1 parent
431a069d4c
commit
b54081f9b4
14 files changed
+516
-64
No files matched your search
+26
-1
@@ -727,6 +727,29 @@ shape="pill"`), then `divider` (a `Separator`). A page search is
|
||||
`label`. The chunk viewer's and file tree's searches stay `CommandInput` in
|
||||
their frame, because their results are `CommandItem`s.
|
||||
|
||||
### App chrome: the phone top bar and New Chat
|
||||
|
||||
Below `lg` the top of every page is `navigation/MobileTopBar` (56px, `h-14`):
|
||||
the sidebar toggle (`PanelLeft`), a title, then New Chat and the account
|
||||
avatar (`ProfileButton size="xs"`). It is on `bg-background` like the page,
|
||||
with no border and no shadow; a `from-background to-transparent` gradient under
|
||||
it lets the content fade out as it scrolls. At `lg` and up, `ActionButtons`
|
||||
holds Share and the avatar in the top-right corner instead, and it renders
|
||||
nothing on a phone.
|
||||
|
||||
The title is for chats only: the open conversation's name, or the agent's
|
||||
name on a new agent chat, with the agent's `Avatar` in front. A plain new chat
|
||||
has no title. So do section pages (settings, admin, an agent's pages), because
|
||||
`SectionShell` already draws their title. When the chat has actions, the title
|
||||
is a `ghost sm` Button that opens a `DropdownMenu` with Edit agent (owned
|
||||
agents), Share, Rename and Delete. Rename edits the name in place, as the
|
||||
sidebar row does. A title with no actions (a shared agent's new chat) is
|
||||
plain text.
|
||||
|
||||
New Chat is `SquarePen` everywhere: the phone bar, the sidebar's New Chat row
|
||||
and the collapsed rail. `Plus` means "add an item to this list", not "start
|
||||
a chat".
|
||||
|
||||
### Grids
|
||||
|
||||
Two recipes, no component. Tiles (sources, tools, custom models, agents):
|
||||
@@ -982,7 +1005,9 @@ shadow-lg` in both themes: the knob is white on any track, and a white knob
|
||||
`z-20` in-page floating chrome (banners, scroll-to-bottom, drag
|
||||
handles); `z-50` overlays, modals, sheets and toasts; `z-200` every
|
||||
portalled floating list (popover, menu, select, tooltip), so it opens above
|
||||
a Modal without an override. Do not invent values in between.
|
||||
a Modal without an override. Do not invent values in between. The app
|
||||
shell uses the low layers too: the phone top bar is `z-10`, and the
|
||||
sidebar and its phone backdrop are `z-20`, so an open sidebar dims the bar.
|
||||
|
||||
## Inline styles
|
||||
|
||||
|
||||
@@ -107,10 +107,10 @@ function MainLayout() {
|
||||
<SidebarLevelProvider>
|
||||
<div className="bg-background relative h-screen overflow-hidden">
|
||||
<Navigation navOpen={navOpen} setNavOpen={setNavOpen} />
|
||||
<ActionButtons showNewChat={!inSection} showShare={!inSection} />
|
||||
<ActionButtons showShare={!inSection} />
|
||||
<div
|
||||
className={cn(
|
||||
'h-[calc(100dvh-64px)] overflow-auto transition-[margin] duration-300 ease-in-out lg:h-screen',
|
||||
'h-[calc(100dvh-56px)] overflow-auto transition-[margin] duration-300 ease-in-out lg:h-screen',
|
||||
!isMobile ? (navOpen ? 'lg:ml-72' : 'lg:ml-14') : 'ml-0 lg:ml-16',
|
||||
)}
|
||||
>
|
||||
|
||||
+37
-19
@@ -4,19 +4,19 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutGrid,
|
||||
Menu,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Pin,
|
||||
PinOff,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
SquarePen,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
AGENTS_MANAGE_ROOT,
|
||||
agentChatPath,
|
||||
agentEditPathFor,
|
||||
sharedAgentPath,
|
||||
} from './agents/paths';
|
||||
import { Agent } from './agents/types';
|
||||
@@ -46,6 +46,7 @@ import JWTModal from './modals/JWTModal';
|
||||
import SearchConversationsModal from './modals/SearchConversationsModal';
|
||||
import { ActiveState } from './models/misc';
|
||||
import { getConversations } from './preferences/preferenceApi';
|
||||
import MobileTopBar from './navigation/MobileTopBar';
|
||||
import SectionNav from './navigation/SectionNav';
|
||||
import SectionRail from './navigation/SectionRail';
|
||||
import SidebarLevel from './navigation/SidebarLevel';
|
||||
@@ -384,11 +385,24 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
setNavOpen(!isMobile);
|
||||
}, [isMobile]);
|
||||
|
||||
// What the phone top bar names: the open chat, else the agent a new chat
|
||||
// is with. A plain new chat and every section (settings, admin, agent
|
||||
// pages) have none; sections carry their own page heading.
|
||||
const currentConversation = conversationId
|
||||
? conversations?.data?.find((c) => c.id === conversationId)
|
||||
: undefined;
|
||||
const ownsSelectedAgent = Boolean(
|
||||
selectedAgent?.id && agents?.some((a) => a.id === selectedAgent.id),
|
||||
);
|
||||
const mobileTitle = routeSection
|
||||
? undefined
|
||||
: (currentConversation?.name ?? selectedAgent?.name);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile && navOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-10 bg-black opacity-50 transition-opacity duration-300"
|
||||
className="fixed inset-0 z-20 bg-black opacity-50 transition-opacity duration-300"
|
||||
onClick={() => setNavOpen(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -426,7 +440,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
size="icon"
|
||||
onClick={() => newChat()}
|
||||
>
|
||||
<Plus aria-hidden />
|
||||
<SquarePen aria-hidden />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
@@ -528,7 +542,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
)
|
||||
}
|
||||
>
|
||||
<Plus
|
||||
<SquarePen
|
||||
className="text-muted-foreground group-hover:text-foreground size-5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
@@ -833,20 +847,24 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-sidebar sticky z-10 h-16 w-full border-b-2 lg:hidden">
|
||||
<div className="relative flex h-full items-center">
|
||||
<IconButton
|
||||
label={t('navigation.openSidebar')}
|
||||
side="bottom"
|
||||
variant="ghost-muted"
|
||||
size="icon"
|
||||
className="ml-4 lg:hidden"
|
||||
onClick={() => setNavOpen(true)}
|
||||
>
|
||||
<Menu aria-hidden />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<MobileTopBar
|
||||
onOpenSidebar={() => setNavOpen(true)}
|
||||
onNewChat={routeSection ? undefined : newChat}
|
||||
title={mobileTitle}
|
||||
agentImage={
|
||||
!routeSection && selectedAgent
|
||||
? (selectedAgent.image ?? '')
|
||||
: undefined
|
||||
}
|
||||
conversationId={routeSection ? null : currentConversation?.id}
|
||||
onRename={updateConversationName}
|
||||
onDelete={handleDeleteConversation}
|
||||
editAgentPath={
|
||||
!routeSection && ownsSelectedAgent && selectedAgent
|
||||
? agentEditPathFor(selectedAgent)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
message={t('modals.deleteConv.confirm')}
|
||||
modalState={modalStateDeleteConv}
|
||||
|
||||
@@ -1,71 +1,40 @@
|
||||
import { Plus, Share } from 'lucide-react';
|
||||
import { Share } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { ShareConversationModal } from '../modals/ShareConversationModal';
|
||||
import { useState } from 'react';
|
||||
import { selectConversationId } from '../preferences/preferenceSlice';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { AppDispatch } from '../store';
|
||||
import {
|
||||
setConversation,
|
||||
updateConversationId,
|
||||
} from '../conversation/conversationSlice';
|
||||
import { IconButton } from './ui/icon-button';
|
||||
import ProfileButton from './ProfileButton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ActionButtonsProps {
|
||||
className?: string;
|
||||
showNewChat?: boolean;
|
||||
showShare?: boolean;
|
||||
isArtifactOpen?: boolean;
|
||||
}
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Share and the account menu in the desktop top-right corner. Below `lg`
|
||||
* the same actions live in `MobileTopBar`.
|
||||
*/
|
||||
export default function ActionButtons({
|
||||
className = '',
|
||||
showNewChat = true,
|
||||
showShare = true,
|
||||
isArtifactOpen = false,
|
||||
}: ActionButtonsProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const conversationId = useSelector(selectConversationId);
|
||||
const [isShareModalOpen, setShareModalState] = useState<boolean>(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const newChat = () => {
|
||||
dispatch(setConversation([]));
|
||||
dispatch(
|
||||
updateConversationId({
|
||||
query: { conversationId: null },
|
||||
}),
|
||||
);
|
||||
navigate('/c/new');
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 z-10 flex h-16 flex-col justify-center transition-[right] duration-300 ease-in-out',
|
||||
'fixed top-0 z-10 hidden h-16 flex-col justify-center transition-[right] duration-300 ease-in-out lg:flex',
|
||||
isArtifactOpen ? 'right-[calc(50%+1rem)]' : 'right-4',
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex items-center gap-2 sm:gap-4', className)}>
|
||||
{showNewChat && (
|
||||
<IconButton
|
||||
label={t('newChat')}
|
||||
side="bottom"
|
||||
variant="ghost-muted"
|
||||
size="icon"
|
||||
shape="pill"
|
||||
onClick={newChat}
|
||||
className="lg:hidden"
|
||||
>
|
||||
<Plus aria-hidden />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{showShare && conversationId && (
|
||||
<>
|
||||
<IconButton
|
||||
|
||||
@@ -12,7 +12,12 @@ import { Separator } from './ui/separator';
|
||||
* showing the signed-in name/email and a sign-out action. Renders nothing for
|
||||
* other auth modes, which carry no user identity.
|
||||
*/
|
||||
export default function ProfileButton() {
|
||||
export default function ProfileButton({
|
||||
size = 'sm',
|
||||
}: {
|
||||
/** Trigger avatar: `sm` (32px) in the desktop corner, `xs` (28px) in the mobile bar. */
|
||||
size?: 'xs' | 'sm';
|
||||
} = {}) {
|
||||
const { t } = useTranslation();
|
||||
const { authType, userName, userEmail, userPicture, logout } = useTokenAuth();
|
||||
|
||||
@@ -22,17 +27,17 @@ export default function ProfileButton() {
|
||||
// `sm` is size-8/text-sm, `lg` is size-10/text-base; the size variant
|
||||
// also brings the flex centring and the overflow-hidden that clips both the
|
||||
// photo and the initials to the circle.
|
||||
const renderAvatar = (size: 'sm' | 'lg') =>
|
||||
const renderAvatar = (avatarSize: 'xs' | 'sm' | 'lg') =>
|
||||
userPicture ? (
|
||||
<Avatar
|
||||
size={size}
|
||||
size={avatarSize}
|
||||
shape="circle"
|
||||
src={userPicture}
|
||||
alt={userName || userEmail || t('components.profile.avatarAlt')}
|
||||
imgClassName="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Avatar size={size} shape="circle" variant="primary">
|
||||
<Avatar size={avatarSize} shape="circle" variant="primary">
|
||||
{initial}
|
||||
</Avatar>
|
||||
);
|
||||
@@ -49,7 +54,7 @@ export default function ProfileButton() {
|
||||
shape="pill"
|
||||
aria-label={t('auth.account')}
|
||||
>
|
||||
{renderAvatar('sm')}
|
||||
{renderAvatar(size)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" sideOffset={8} className="w-64 p-0">
|
||||
|
||||
@@ -2195,6 +2195,7 @@
|
||||
},
|
||||
"openSidebar": "Seitenleiste öffnen",
|
||||
"closeSidebar": "Seitenleiste schließen",
|
||||
"editAgent": "Agent bearbeiten",
|
||||
"social": {
|
||||
"discord": "Der Discord-Community beitreten",
|
||||
"x": "Folge uns auf X",
|
||||
|
||||
@@ -2231,6 +2231,7 @@
|
||||
},
|
||||
"openSidebar": "Open sidebar",
|
||||
"closeSidebar": "Close sidebar",
|
||||
"editAgent": "Edit agent",
|
||||
"social": {
|
||||
"discord": "Join Discord community",
|
||||
"x": "Follow us on X",
|
||||
|
||||
@@ -2195,6 +2195,7 @@
|
||||
},
|
||||
"openSidebar": "Abrir barra lateral",
|
||||
"closeSidebar": "Cerrar barra lateral",
|
||||
"editAgent": "Editar agente",
|
||||
"social": {
|
||||
"discord": "Únete a la comunidad de Discord",
|
||||
"x": "Síguenos en X",
|
||||
|
||||
@@ -2189,6 +2189,7 @@
|
||||
},
|
||||
"openSidebar": "サイドバーを開く",
|
||||
"closeSidebar": "サイドバーを閉じる",
|
||||
"editAgent": "エージェントを編集",
|
||||
"social": {
|
||||
"discord": "Discord コミュニティに参加",
|
||||
"x": "X でフォロー",
|
||||
|
||||
@@ -2253,6 +2253,7 @@
|
||||
},
|
||||
"openSidebar": "Открыть боковую панель",
|
||||
"closeSidebar": "Закрыть боковую панель",
|
||||
"editAgent": "Редактировать агента",
|
||||
"social": {
|
||||
"discord": "Присоединиться к сообществу в Discord",
|
||||
"x": "Следите за нами в X",
|
||||
|
||||
@@ -2189,6 +2189,7 @@
|
||||
},
|
||||
"openSidebar": "開啟側邊欄",
|
||||
"closeSidebar": "關閉側邊欄",
|
||||
"editAgent": "編輯代理",
|
||||
"social": {
|
||||
"discord": "加入 Discord 社群",
|
||||
"x": "在 X 上追蹤我們",
|
||||
|
||||
@@ -2189,6 +2189,7 @@
|
||||
},
|
||||
"openSidebar": "打开侧边栏",
|
||||
"closeSidebar": "关闭侧边栏",
|
||||
"editAgent": "编辑代理",
|
||||
"social": {
|
||||
"discord": "加入 Discord 社区",
|
||||
"x": "在 X 上关注我们",
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
// Both read the store; the bar only decides when they appear.
|
||||
vi.mock('../components/ProfileButton', () => ({
|
||||
default: () => <span data-testid="profile" />,
|
||||
}));
|
||||
vi.mock('../modals/ShareConversationModal', () => ({
|
||||
ShareConversationModal: ({ conversationId }: { conversationId: string }) => (
|
||||
<div data-testid="share-modal">{conversationId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import MobileTopBar from './MobileTopBar';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const openMenu = (trigger: Element) =>
|
||||
act(() => {
|
||||
trigger.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
(trigger as HTMLElement).click();
|
||||
});
|
||||
|
||||
const menuItem = (label: string) =>
|
||||
Array.from(document.querySelectorAll('[role="menuitem"]')).find(
|
||||
(el) => el.textContent === label,
|
||||
) as HTMLElement | undefined;
|
||||
|
||||
describe('MobileTopBar', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = (props: Partial<Parameters<typeof MobileTopBar>[0]>) =>
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<MobileTopBar onOpenSidebar={() => {}} {...props} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const button = (label: string) =>
|
||||
container.querySelector(
|
||||
`button[aria-label="${label}"]`,
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
it('shows the sidebar toggle, new chat and profile on an empty chat, with no title', () => {
|
||||
const onOpenSidebar = vi.fn();
|
||||
const onNewChat = vi.fn();
|
||||
render({ onOpenSidebar, onNewChat });
|
||||
|
||||
act(() => button('navigation.openSidebar')!.click());
|
||||
act(() => button('newChat')!.click());
|
||||
expect(onOpenSidebar).toHaveBeenCalledOnce();
|
||||
expect(onNewChat).toHaveBeenCalledOnce();
|
||||
expect(container.querySelector('[data-testid="profile"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="mobile-title"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows no title and no new chat in a section (settings, admin)', () => {
|
||||
render({});
|
||||
|
||||
expect(container.querySelector('[data-testid="mobile-title"]')).toBeNull();
|
||||
expect(button('newChat')).toBeNull();
|
||||
expect(button('navigation.openSidebar')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows a shared agent name as plain text: it has no actions', () => {
|
||||
render({ title: 'Support Assistant', agentImage: '', onNewChat: () => {} });
|
||||
|
||||
const title = container.querySelector('[data-testid="mobile-title"]');
|
||||
expect(title?.textContent).toBe('Support Assistant');
|
||||
expect(title?.tagName).not.toBe('BUTTON');
|
||||
});
|
||||
|
||||
it('opens share, rename and delete from the conversation title', () => {
|
||||
render({
|
||||
title: 'Router drops',
|
||||
conversationId: 'c1',
|
||||
onNewChat: () => {},
|
||||
onRename: () => {},
|
||||
onDelete: () => {},
|
||||
});
|
||||
|
||||
const title = container.querySelector('[data-testid="mobile-title"]')!;
|
||||
expect(title.tagName).toBe('BUTTON');
|
||||
expect(title.textContent).toContain('Router drops');
|
||||
openMenu(title);
|
||||
expect(menuItem('convTile.share')).toBeDefined();
|
||||
expect(menuItem('convTile.rename')).toBeDefined();
|
||||
expect(menuItem('convTile.delete')).toBeDefined();
|
||||
expect(menuItem('navigation.editAgent')).toBeUndefined();
|
||||
|
||||
act(() => menuItem('convTile.share')!.click());
|
||||
expect(
|
||||
document.querySelector('[data-testid="share-modal"]')?.textContent,
|
||||
).toBe('c1');
|
||||
});
|
||||
|
||||
it('renames the conversation in place', () => {
|
||||
const onRename = vi.fn();
|
||||
render({
|
||||
title: 'Router drops',
|
||||
conversationId: 'c1',
|
||||
onRename,
|
||||
onDelete: () => {},
|
||||
});
|
||||
|
||||
openMenu(container.querySelector('[data-testid="mobile-title"]')!);
|
||||
act(() => menuItem('convTile.rename')!.click());
|
||||
const input = container.querySelector('input') as HTMLInputElement;
|
||||
expect(input.value).toBe('Router drops');
|
||||
|
||||
act(() => {
|
||||
const setValue = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
)!.set!;
|
||||
setValue.call(input, 'TP-Link fix');
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
act(() => button('convTile.save')!.click());
|
||||
expect(onRename).toHaveBeenCalledWith({ id: 'c1', name: 'TP-Link fix' });
|
||||
expect(container.querySelector('input')).toBeNull();
|
||||
});
|
||||
|
||||
it('adds Edit agent for an owned agent, even before the chat has a title', () => {
|
||||
render({
|
||||
title: 'Music score analyst',
|
||||
agentImage: '/robot.svg',
|
||||
editAgentPath: '/agents/edit/a1',
|
||||
});
|
||||
|
||||
const title = container.querySelector('[data-testid="mobile-title"]')!;
|
||||
expect(title.querySelector('img')).not.toBeNull();
|
||||
openMenu(title);
|
||||
expect(menuItem('navigation.editAgent')).toBeDefined();
|
||||
expect(menuItem('convTile.share')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
PanelLeft,
|
||||
Pencil,
|
||||
Share,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import ProfileButton from '../components/ProfileButton';
|
||||
import { Avatar } from '../components/ui/avatar';
|
||||
import { Button } from '../components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../components/ui/dropdown-menu';
|
||||
import { IconButton } from '../components/ui/icon-button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import { ShareConversationModal } from '../modals/ShareConversationModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
|
||||
interface MobileTopBarProps {
|
||||
onOpenSidebar: () => void;
|
||||
/** Omitted in sections (settings, admin), where there is no chat to leave. */
|
||||
onNewChat?: () => void;
|
||||
/** Chat or agent name. An empty chat and a section have none. */
|
||||
title?: string;
|
||||
/** Present on an agent chat: its image, shown before the title. */
|
||||
agentImage?: string;
|
||||
/** The open conversation; turns on Share, Rename and Delete. */
|
||||
conversationId?: string | null;
|
||||
onRename?: (conversation: { id: string; name: string }) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
/** Set for an agent the user owns; adds "Edit agent" to the title menu. */
|
||||
editAgentPath?: string;
|
||||
}
|
||||
|
||||
type TitleAction = {
|
||||
key: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
onSelect: () => void;
|
||||
destructive?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The phone-width top bar (below `lg`): sidebar toggle, the current chat or
|
||||
* agent name, then new chat and the account menu.
|
||||
*
|
||||
* It sits on the page surface with no border; the content fades out under a
|
||||
* gradient instead. On a conversation or agent the title opens a menu with
|
||||
* the chat's actions, so they are reachable without opening the sidebar.
|
||||
*
|
||||
* @param onOpenSidebar Opens the sidebar sheet.
|
||||
* @param onNewChat Starts a new chat; omit it to hide the button.
|
||||
* @param title The name shown next to the toggle.
|
||||
* @param agentImage The agent's image, when the chat belongs to one.
|
||||
* @param conversationId The open conversation, which enables its actions.
|
||||
* @param onRename Saves a new conversation name.
|
||||
* @param onDelete Deletes the conversation after confirmation.
|
||||
* @param editAgentPath The agent's editor route, for an owned agent.
|
||||
*/
|
||||
export default function MobileTopBar({
|
||||
onOpenSidebar,
|
||||
onNewChat,
|
||||
title,
|
||||
agentImage,
|
||||
conversationId,
|
||||
onRename,
|
||||
onDelete,
|
||||
editAgentPath,
|
||||
}: MobileTopBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draftName, setDraftName] = useState('');
|
||||
const [isShareOpen, setIsShareOpen] = useState(false);
|
||||
const [deleteModalState, setDeleteModalState] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
|
||||
const actions: TitleAction[] = [];
|
||||
if (editAgentPath) {
|
||||
actions.push({
|
||||
key: 'edit-agent',
|
||||
icon: Bot,
|
||||
label: t('navigation.editAgent'),
|
||||
onSelect: () => navigate(editAgentPath),
|
||||
});
|
||||
}
|
||||
if (conversationId) {
|
||||
actions.push(
|
||||
{
|
||||
key: 'share',
|
||||
icon: Share,
|
||||
label: t('convTile.share'),
|
||||
onSelect: () => setIsShareOpen(true),
|
||||
},
|
||||
{
|
||||
key: 'rename',
|
||||
icon: Pencil,
|
||||
label: t('convTile.rename'),
|
||||
onSelect: () => {
|
||||
setDraftName(title ?? '');
|
||||
setIsEditing(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
icon: Trash2,
|
||||
label: t('convTile.delete'),
|
||||
onSelect: () => setDeleteModalState('ACTIVE'),
|
||||
destructive: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const saveName = () => {
|
||||
const name = draftName.trim();
|
||||
if (conversationId && name && name !== title?.trim()) {
|
||||
onRename?.({ id: conversationId, name });
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const agentMark = agentImage !== undefined && (
|
||||
<Avatar
|
||||
src={agentImage}
|
||||
alt=""
|
||||
shape="circle"
|
||||
className="shrink-0 overflow-hidden"
|
||||
imgClassName="size-5 object-contain"
|
||||
/>
|
||||
);
|
||||
|
||||
const renderTitle = () => {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="bg-accent flex h-8 min-w-0 flex-1 items-center gap-1 rounded-md pr-1 pl-2">
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
variant="bare"
|
||||
className="min-w-0 flex-1"
|
||||
aria-label={t('convTile.rename')}
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') saveName();
|
||||
else if (e.key === 'Escape') setIsEditing(false);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
label={t('convTile.save')}
|
||||
icon={Check}
|
||||
variant="ghost-on-accent"
|
||||
size="icon-xs"
|
||||
onClick={saveName}
|
||||
/>
|
||||
<IconButton
|
||||
label={t('cancel')}
|
||||
icon={X}
|
||||
variant="ghost-on-accent"
|
||||
size="icon-xs"
|
||||
onClick={() => setIsEditing(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!title) return null;
|
||||
if (actions.length === 0) {
|
||||
return (
|
||||
<span
|
||||
data-testid="mobile-title"
|
||||
className="text-foreground flex min-w-0 items-center gap-1.5 px-2 text-sm font-medium"
|
||||
>
|
||||
{agentMark}
|
||||
<span className="truncate">{title}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid="mobile-title"
|
||||
className="min-w-0"
|
||||
>
|
||||
{agentMark}
|
||||
<span className="truncate">{title}</span>
|
||||
<ChevronDown className="text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-48">
|
||||
{actions.map((action) => (
|
||||
<DropdownMenuItem
|
||||
key={action.key}
|
||||
variant={action.destructive ? 'destructive' : 'default'}
|
||||
onSelect={action.onSelect}
|
||||
>
|
||||
<action.icon aria-hidden />
|
||||
<span>{action.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-background relative z-10 flex h-14 w-full items-center gap-1 px-2 lg:hidden">
|
||||
<IconButton
|
||||
label={t('navigation.openSidebar')}
|
||||
side="bottom"
|
||||
variant="ghost-muted"
|
||||
size="icon"
|
||||
icon={PanelLeft}
|
||||
onClick={onOpenSidebar}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1">{renderTitle()}</div>
|
||||
<div className="flex shrink-0 items-center gap-1 pr-1">
|
||||
{onNewChat && (
|
||||
<IconButton
|
||||
label={t('newChat')}
|
||||
side="bottom"
|
||||
variant="ghost-muted"
|
||||
size="icon"
|
||||
icon={SquarePen}
|
||||
onClick={onNewChat}
|
||||
/>
|
||||
)}
|
||||
<ProfileButton size="xs" />
|
||||
</div>
|
||||
{/* Replaces a border: the content dissolves under the bar. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="from-background pointer-events-none absolute inset-x-0 top-full h-6 bg-linear-to-b to-transparent"
|
||||
/>
|
||||
</div>
|
||||
{conversationId && (
|
||||
<ConfirmationModal
|
||||
message={t('convTile.deleteWarning')}
|
||||
modalState={deleteModalState}
|
||||
setModalState={setDeleteModalState}
|
||||
handleSubmit={() => onDelete?.(conversationId)}
|
||||
submitLabel={t('convTile.delete')}
|
||||
variant="destructive"
|
||||
/>
|
||||
)}
|
||||
{isShareOpen && conversationId && (
|
||||
<ShareConversationModal
|
||||
close={() => setIsShareOpen(false)}
|
||||
conversationId={conversationId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user