mobile top nav

This commit is contained in:
Pavel committed 2026-09-26 20:09:30 +04:00
1 parent 431a069d4c
commit b54081f9b4
14 files changed
+516 -64

No files matched your search

+26 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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}
+6 -37
View File
@@ -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
+10 -5
View File
@@ -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">
+1
View File
@@ -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",
+1
View File
@@ -2231,6 +2231,7 @@
},
"openSidebar": "Open sidebar",
"closeSidebar": "Close sidebar",
"editAgent": "Edit agent",
"social": {
"discord": "Join Discord community",
"x": "Follow us on X",
+1
View File
@@ -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",
+1
View File
@@ -2189,6 +2189,7 @@
},
"openSidebar": "サイドバーを開く",
"closeSidebar": "サイドバーを閉じる",
"editAgent": "エージェントを編集",
"social": {
"discord": "Discord コミュニティに参加",
"x": "X でフォロー",
+1
View File
@@ -2253,6 +2253,7 @@
},
"openSidebar": "Открыть боковую панель",
"closeSidebar": "Закрыть боковую панель",
"editAgent": "Редактировать агента",
"social": {
"discord": "Присоединиться к сообществу в Discord",
"x": "Следите за нами в X",
+1
View File
@@ -2189,6 +2189,7 @@
},
"openSidebar": "開啟側邊欄",
"closeSidebar": "關閉側邊欄",
"editAgent": "編輯代理",
"social": {
"discord": "加入 Discord 社群",
"x": "在 X 上追蹤我們",
+1
View File
@@ -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();
});
});
+271
View File
@@ -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}
/>
)}
</>
);
}