Merge pull request #2833 from arc53/ui-polish

This commit is contained in:
Alex authored and GitHub committed 2026-09-26 21:04:02 +01:00
commit 420b4d33b9
44 files changed
+913 -141

No files matched your search

+107 -8
View File
@@ -182,6 +182,10 @@ Patterns:
link.
- Icon-only buttons are `IconButton` (below), never a `Button` with a
`title`.
- An action whose label doesn't fit beside a name on a phone (the shared
agent card's Edit, in a long locale) is two elements: an `IconButton`
with `sm:hidden` and the labelled `Button` with `hidden sm:inline-flex`.
Never hide a Button's label span, which leaves an icon-only Button.
- Roles for dangerous and dismissive actions: a delete on a page (a "Danger
zone" card's Delete agent or Revoke, Delete all) is `destructive-outline`;
the submit of a confirm dialog is `destructive` (`ModalActions destructive`,
@@ -294,6 +298,8 @@ tone="destructive"`. Muted text fails AA on the red fill, so the tone turns
ghost's grey square would not.
- A card on the page background that must not look raised (the shared
agent card) is `subtle lg`. Cards never take a shadow.
- The shared agent card's description is `line-clamp-3` with no inner
scroller (`max-h-* overflow-y-auto`) and no hover hint for the cut text.
Stat tiles are `components/StatCard.tsx`, never a hand-rolled Card: `label`,
`value` (24px bold `tabular-nums`), `sub` (a 12px muted line or link),
@@ -311,6 +317,9 @@ with `className="flex-row items-start gap-2"` and the `CopyButton` inside.
Inside an Alert or a trace's tool panel, and in a full-pane viewer, the
`<pre>` takes the recipe with no Card, so boxes don't nest. Put scroll caps
(`max-h-* overflow-y-auto`) on the Card; never `break-all`.
This recipe is for app output. A fenced code block in a markdown answer is
source code: it keeps its lines (`white-space: pre`, indentation intact) and
scrolls sideways inside its own bordered frame (see Chat answer column).
Tile text: the name is `CardTitle` (14px semibold from Card's `text-sm`; pass
`as="h2"` on a page that goes from its title straight to a tile grid, but
@@ -727,6 +736,54 @@ 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.
A long title truncates to one line and never runs under New Chat or the
avatar: the title slot is `flex min-w-0 flex-1`, the agent's `Avatar` and the
chevron keep their size, and only the name gives way (a `truncate` span with
`title=` holding the full name). This is the rule for any Button that holds a
user-supplied name (a chat, agent or source) in a flex row: Button's base is
`shrink-0 whitespace-nowrap`, so `min-w-0` alone does nothing. Pass `min-w-0
shrink` (or `flex-1` when it should fill the row) and put the name in the
`truncate` span; its icons stay `shrink-0`. `w-full` doesn't count: a
`w-full shrink-0` Button takes the whole row and pushes its neighbour out,
under the next control (an `outline` Button's fill is translucent in dark, so
it shows through there and hides in light). `variant="combobox"` already
carries `min-w-0 shrink`, like the fields it sits among.
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".
### Chat answer column
The answer column never scrolls sideways. Its boxes, from AnswerFlow down to
MarkdownAnswer, are stretched full width (`w-full min-w-0`, or the flex
default), never `items-start` / `self-start` with `max-w-full`: a
shrink-to-fit box sizes to its longest code line, and `max-w-full` caps it at
100% before its margins are added, so it still spills past a phone screen.
Wide markdown blocks scroll inside their own frame, not the page: fenced code
scrolls sideways in its bordered box with the language and copy row fixed
above it, and tables do the same (`overflow-x-auto` on their bordered
wrapper).
### Grids
Two recipes, no component. Tiles (sources, tools, custom models, agents):
@@ -799,14 +856,18 @@ scroller) and the same blurred overlay. Modal, DialogContent and every Sheet sha
dark:bg-black/50`).
Every phone bottom sheet has one shape: `bg-card`, 16px top corners, no top
border, at most 90% of the viewport, and bottom padding that clears the iPhone
home indicator. `SheetContent side="bottom"` gives it with `pb-safe-0` (the
bare inset); pass `handle` for the grab bar, which also hides the X (the handle and
the overlay dismiss it; pass `showCloseButton` to keep one).
border, `max-h-sheet` (the visible viewport less the top safe-area inset and a
3rem strip, so the scrim above it can always be tapped to close), and bottom
padding that clears the iPhone home indicator. Never cap a sheet with `vh` (see
Viewport heights): a `90vh` sheet slides under Safari's URL bar and covers its
scrim. `SheetContent side="bottom"` gives the
shape with `pb-safe-0` (the bare inset); pass `handle` for the grab bar, which
also hides the X (the overlay dismisses it; the handle is only a cue and doesn't
drag; pass `showCloseButton` to keep an X).
`Modal mobileVariant="sheet"` shares the shape and the `SheetHandle`, with
`pb-safe` (the inset, at least 1rem) under its footer. `pb-safe` and
`pb-safe-0` are the `index.css` utilities for `env(safe-area-inset-bottom)`;
never spell `env()` in a class.
`pb-safe` (the inset, at least 1rem) under its footer. `pb-safe`, `pb-safe-0`
and `max-h-sheet` are the `index.css` utilities for the safe-area insets; never
spell `env()` in a class.
**Open question: side panel or right sheet for chat content.** Chat has two
ways to show something beside an answer. Notes, todos and files open in
@@ -827,6 +888,13 @@ follows the pointer and arrow keys, so an accent fill would look like hover.
While a checked item is also highlighted it keeps its tint and text and gains
a 1px inset `primary` ring, so the chosen row never turns plain grey.
A picker's footer (`MultiSelectPopover footer`) links to the page that manages
the list, as a `link inline` Button with a 12px `ArrowRight` (Go to Sources, Go
to Tools). A shortcut action (Upload new, an `outline-primary pill`) sits at the
right end of the same row: `flex flex-wrap items-center justify-between gap-3`,
so on a narrow sheet or in a long locale it wraps under the link rather than
taking a row of its own everywhere (`SourcesPopoverFooter`).
### ActionMenu (`ui/dropdown-menu.tsx`)
The three-dots menu on a card, tile or row. Pass `options: MenuOption[]`
@@ -873,6 +941,28 @@ values (`h-[calc(100dvh-64px)]`, `max-w-[520px]`) and motion values
padding, colours and radii in brackets are not; pick the nearest scale step
or add a token.
### Viewport heights
Never size anything with `vh` or the `h-screen` family (`h-`, `min-h-`,
`max-h-screen`, which are `100vh`). On iOS Safari `vh` is the viewport with the
toolbars hidden, so while the URL bar and tab bar show, a `vh` height is about
140px taller than the screen: a centred message sits low, a panel or dropdown
runs under the tab bar, and the app shell can be dragged and bounce. ESLint
(`no-restricted-syntax`) rejects both. Pick by what the height does:
- **`dvh`** (the visible viewport, follows the toolbars): the app shell
(`h-dvh`), full-screen states (`min-h-dvh`: 404, MobileBlocker, the `screen`
`LoadingState`), and caps on things that pop up over the page (a search
dropdown's `max-h-[calc(100dvh-200px)]`, `Modal`'s `85dvh`).
- **`svh`** (the smallest viewport, steady): a fixed-size panel inside a page
that scrolls (the Logs panel's `h-[55svh]`, a list's `max-h-[45svh]`, the
chunk editor's `min-h`), so it doesn't resize while Safari's bars slide in
and out.
- **`max-h-sheet`**: every bottom sheet (see Modal, not Dialog).
- A row or control has a fixed height (`h-12`), never a share of the viewport.
`w-screen` is `vw`, which the toolbars don't change, so it stays allowed.
### Typography roles
- **Page title**: `SectionPageHeader` (24px bold); the only other text at
@@ -900,6 +990,13 @@ leading-tight font-semibold`. `DialogTitle` and `SheetTitle` default to it;
or output) stays proportional at the same size; only what the app
serialised (arguments, results, attributes) is mono. Every stat figure is
`tabular-nums`.
- **Wrapping**: prose that can run long (a name, a description) wraps with
`wrap-break-word` in a column that can shrink (`min-w-0` in a flex row).
A single long token (a URL, a key, an id, an email, a filename, a command)
is `wrap-anywhere`, which also lets a table cell or flex item shrink below
the token instead of widening the table. Both break at spaces first and
split a token only when it would overflow. Never `break-all`: it splits
ordinary words mid-word ("notif / ications").
### Rhythm
@@ -982,7 +1079,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
+22
View File
@@ -84,6 +84,28 @@ export default [
],
},
],
// On iOS Safari vh (and h-screen, which is 100vh) is the viewport with
// the toolbars hidden, so a vh height overflows the visible screen.
// w-screen is vw, which the toolbars don't change, so it stays allowed.
'no-restricted-syntax': [
'error',
...[
'Literal[value=/\\dvh\\b/]',
'TemplateElement[value.raw=/\\dvh\\b/]',
].map((selector) => ({
selector,
message:
'vh is the toolbar-hidden viewport on iOS Safari. Use dvh for the app shell and caps on things that pop up, svh for a fixed-size panel in a page that scrolls, or max-h-sheet for a bottom sheet. See DESIGN.md.',
})),
...[
'Literal[value=/(^|[\\s:])(min-|max-)?h-screen\\b/]',
'TemplateElement[value.raw=/(^|[\\s:])(min-|max-)?h-screen\\b/]',
].map((selector) => ({
selector,
message:
"Tailwind's screen heights (h-/min-h-/max-h-screen) are the toolbar-hidden viewport on iOS Safari, taller than the visible screen. Use h-dvh / min-h-dvh, or svh for a fixed panel. See DESIGN.md.",
})),
],
// Design-system rules (@shadcn/lint). Tokens, variants and the
// approved exceptions are documented in DESIGN.md.
'shadcn/no-restyle': [
+4 -4
View File
@@ -64,7 +64,7 @@ function AuthWrapper({ children }: { children: React.ReactNode }) {
? t('auth.accountDisabled')
: t('auth.signInToContinue');
return (
<div className="flex h-screen flex-col items-center justify-center gap-6">
<div className="flex h-dvh flex-col items-center justify-center gap-6">
<img
src={isDarkTheme ? DocsGPTMarkWhite : DocsGPTMark}
alt="DocsGPT"
@@ -105,12 +105,12 @@ function MainLayout() {
return (
<SidebarLevelProvider>
<div className="bg-background relative h-screen overflow-hidden">
<div className="bg-background relative h-dvh 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-dvh',
!isMobile ? (navOpen ? 'lg:ml-72' : 'lg:ml-14') : 'ml-0 lg:ml-16',
)}
>
+38 -20
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
@@ -475,7 +489,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
>
<div
className={
'visible mt-2 flex h-[6vh] w-full items-center justify-between gap-1 px-2 md:h-12'
'visible mt-2 flex h-12 w-full items-center justify-between gap-1 px-2'
}
>
<div className="min-w-0 flex-1">
@@ -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 -1
View File
@@ -5,7 +5,7 @@ export default function PageNotFound() {
const { t } = useTranslation();
return (
<div className="bg-background grid min-h-screen">
<div className="bg-background grid min-h-dvh">
<div className="text-foreground bg-muted mx-auto my-auto mt-20 flex w-full max-w-6xl flex-col place-items-center gap-6 rounded-3xl p-6 lg:p-10 xl:p-16">
<h1 className="text-3xl">{t('pageNotFound.title')}</h1>
<p>{t('pageNotFound.message')}</p>
+1 -1
View File
@@ -81,7 +81,7 @@ export default function Admins() {
<TableBody>
{admins.map((a) => (
<TableRow key={a.user_id}>
<TableCell className="font-medium break-all">
<TableCell className="font-medium wrap-anywhere">
{a.user_id}
</TableCell>
<TableCell>
+1 -1
View File
@@ -308,7 +308,7 @@ export default function Quotas() {
{userPolicies.map((policy) => (
<TableRow key={`${policy.subject_id}:${policy.bucket}`}>
<TableCell>
<span className="mr-2 break-all">
<span className="mr-2 wrap-anywhere">
{policy.subject_id}
</span>
{bucketPill(policy)}
+1 -1
View File
@@ -224,7 +224,7 @@ export default function Usage() {
<TableBody>
{topUsers.map((user) => (
<TableRow key={user.user_id}>
<TableCell className="font-mono text-xs break-all">
<TableCell className="font-mono text-xs wrap-anywhere">
<Button
type="button"
variant="link"
+1 -1
View File
@@ -78,7 +78,7 @@ function SplitTable({
<TableBody>
{rows.map((row) => (
<TableRow key={row.key}>
<TableCell className="text-xs break-all">
<TableCell className="text-xs wrap-anywhere">
{labels?.[row.key] ?? row.key}
</TableCell>
<TableCell align="right" className="tabular-nums">
+27 -15
View File
@@ -6,6 +6,7 @@ import { Avatar } from '../components/ui/avatar';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
import { IconButton } from '../components/ui/icon-button';
import { formatDateTime } from '../utils/dateTimeUtils';
import { getToolDisplayName } from '../utils/toolUtils';
import { Agent } from './types';
@@ -40,27 +41,38 @@ export default function SharedAgentCard({
imgClassName="size-full object-contain"
/>
</div>
<div className="flex max-h-[92px] flex-1 flex-col gap-px">
<h2 className="text-foreground text-base font-semibold sm:text-lg">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<h2 className="text-foreground text-base leading-snug font-semibold wrap-break-word sm:text-lg">
{agent.name}
</h2>
<p className="text-muted-foreground overflow-y-auto text-xs text-wrap break-all sm:text-sm">
<p className="text-muted-foreground line-clamp-3 text-xs leading-relaxed wrap-break-word sm:text-sm">
{agent.description}
</p>
</div>
{onEdit && (
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={onEdit}
className="shrink-0"
aria-label={t('agents.edit')}
>
<Pencil />
{t('agents.edit')}
</Button>
<>
{/* A phone has no room for a long localised label beside the name. */}
<IconButton
label={t('agents.edit')}
icon={Pencil}
variant="outline"
size="icon-sm"
shape="pill"
onClick={onEdit}
className="shrink-0 sm:hidden"
/>
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={onEdit}
className="hidden shrink-0 sm:inline-flex"
>
<Pencil />
{t('agents.edit')}
</Button>
</>
)}
</div>
{hasSharedMetadata && (
@@ -251,7 +251,7 @@ export default function GuardrailEvents({ agentId }: Props) {
</div>
<div className="border-border bg-card mt-3 w-full overflow-hidden rounded-xl border">
<div className="max-h-[45vh] overflow-y-auto">
<div className="max-h-[45svh] overflow-y-auto">
{loading ? (
<div className="p-3">
<SkeletonLoader count={3} />
@@ -1778,7 +1778,7 @@ function WorkflowBuilderInner() {
return (
<>
<MobileBlocker />
<div className="bg-background fixed inset-0 z-50 hidden h-screen w-full flex-col lg:flex">
<div className="bg-background fixed inset-0 z-50 hidden h-dvh w-full flex-col lg:flex">
<div className="border-border bg-card flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-4">
{canManageAgent ? (
@@ -2136,7 +2136,7 @@ function WorkflowBuilderInner() {
</IconButton>
</div>
<div className="max-h-[calc(100vh-200px)] overflow-y-auto p-4">
<div className="max-h-[calc(100dvh-200px)] overflow-y-auto p-4">
<div className="mb-4 flex flex-col gap-5">
<div className="bg-muted rounded-lg p-3">
<div className="text-muted-foreground mb-1 text-xs">
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
export default function MobileBlocker() {
const { t } = useTranslation();
return (
<div className="bg-background flex min-h-screen flex-col items-center justify-center px-6 text-center lg:hidden">
<div className="bg-background flex min-h-dvh flex-col items-center justify-center px-6 text-center lg:hidden">
<div className="bg-secondary mb-6 flex size-20 items-center justify-center rounded-2xl">
<Monitor className="text-primary size-10" />
</div>
+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
+5 -3
View File
@@ -98,7 +98,9 @@ const LineNumberedTextarea: React.FC<LineNumberedTextareaProps> = ({
<textarea
className={cn(
'text-foreground focus-visible:ring-ring/50 focus-visible:border-ring h-(--numbered-height) w-full resize-none overflow-hidden border-none bg-transparent pl-8 text-sm leading-5 outline-none focus-visible:ring-3 lg:pl-12',
isMobile ? 'min-h-[calc(100vh-200px)]' : 'min-h-[calc(100vh-300px)]',
isMobile
? 'min-h-[calc(100svh-200px)]'
: 'min-h-[calc(100svh-300px)]',
!editable && 'select-none',
)}
value={value}
@@ -511,7 +513,7 @@ const Chunks: React.FC<ChunksProps> = ({
{fileSearchQuery && (
<div className="border-border bg-popover text-popover-foreground absolute top-full right-0 left-0 z-20 mt-1 w-full overflow-hidden rounded-xl border shadow-md">
<CommandList className="max-h-[calc(100vh-200px)]">
<CommandList className="max-h-[calc(100dvh-200px)]">
{fileSearchResults.length === 0 ? (
<CommandEmpty>{t('settings.sources.noResults')}</CommandEmpty>
) : (
@@ -603,7 +605,7 @@ const Chunks: React.FC<ChunksProps> = ({
<EmptyState
size="sm"
title={t('settings.sources.noChunks')}
className="col-span-full min-h-[50vh] w-full"
className="col-span-full min-h-[50svh] w-full"
/>
) : (
filteredChunks.map((chunk, index) => (
@@ -135,7 +135,7 @@ function DownloadCard({
<div className="border-border flex flex-col items-center gap-3 rounded-xl border p-8 text-center">
<FileText className="text-muted-foreground size-12" />
<div>
<p className="text-foreground text-sm font-medium break-all">
<p className="text-foreground text-sm font-medium wrap-anywhere">
{filename}
</p>
{size != null && (
@@ -240,7 +240,7 @@ export function MultiSelectPopover({
align={align}
side={side}
className={cn(
'flex max-h-[min(600px,80vh)] w-[min(462px,calc(100vw-20px))] flex-col overflow-hidden p-0',
'flex max-h-[min(600px,80dvh)] w-[min(462px,calc(100vw-20px))] flex-col overflow-hidden p-0',
className,
)}
>
+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">
+14 -6
View File
@@ -33,11 +33,22 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
// One placeholder beside the desktop shell, at most two below it.
const skeletonCount = isDesktop ? 1 : Math.min(count, 2);
// The Spinner this replaces carried role="status"; without it a screen
// reader gets no signal at all while a section loads. Absolutely
// positioned by `sr-only`, so it never becomes a flex/grid item.
const status = (
<span className="sr-only" role="status">
{t('loading')}
</span>
);
// Rendered inside a <tbody>, so the status lives in the first cell.
const renderTable = () => (
<>
{[...Array(4)].map((_, idx) => (
<tr key={idx}>
<td className="w-[40%] px-4 py-4">
{idx === 0 && status}
<Skeleton className="h-4 w-full" />
</td>
<td className="w-[30%] px-4 py-4">
@@ -359,14 +370,11 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
const render = componentMap[component] || componentMap.default;
// The Spinner this replaces carried role="status"; without it a screen
// reader gets no signal at all while a section loads. Absolutely
// positioned by `sr-only`, so it never becomes a flex/grid item.
if (component === 'fileTable') return render();
return (
<>
<span className="sr-only" role="status">
{t('loading')}
</span>
{status}
{render()}
</>
);
@@ -0,0 +1,70 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import SourcesPopoverFooter from './SourcesPopoverFooter';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const renderFooter = async (onNavigate = vi.fn(), onUploadClick = vi.fn()) => {
await act(async () =>
root.render(
<MemoryRouter>
<SourcesPopoverFooter
onNavigate={onNavigate}
onUploadClick={onUploadClick}
/>
</MemoryRouter>,
),
);
return { onNavigate, onUploadClick };
};
describe('SourcesPopoverFooter', () => {
it('puts the link and Upload new in one row that wraps when it does not fit', async () => {
await renderFooter();
const row = container.firstElementChild as HTMLElement;
const classes = row.className.split(' ');
expect(classes).toEqual(
expect.arrayContaining([
'flex',
'flex-wrap',
'items-center',
'justify-between',
]),
);
expect(classes).not.toContain('flex-col');
});
it('renders the sources link as an inline link Button', async () => {
await renderFooter();
const link = container.querySelector('a')!;
expect(link.getAttribute('href')).toBe('/settings/sources');
expect(link.dataset.slot).toBe('button');
expect(link.className).toContain('text-primary');
expect(link.className).toContain('text-sm');
});
it('fires onNavigate from the link and onUploadClick from the button', async () => {
const { onNavigate, onUploadClick } = await renderFooter();
await act(async () => container.querySelector('a')!.click());
await act(async () => container.querySelector('button')!.click());
expect(onNavigate).toHaveBeenCalledOnce();
expect(onUploadClick).toHaveBeenCalledOnce();
});
});
@@ -18,21 +18,20 @@ export default function SourcesPopoverFooter({
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<Link
to="/settings/sources"
className="text-primary inline-flex items-center gap-2 text-base font-medium"
onClick={onNavigate}
>
{t('settings.sources.goToSources')}
<ArrowRight className="size-3" />
</Link>
// One row when it fits (link left, upload right); on a narrow sheet or a
// long locale the button wraps under the link.
<div className="flex flex-wrap items-center justify-between gap-3">
<Button variant="link" size="inline" asChild>
<Link to="/settings/sources" onClick={onNavigate}>
{t('settings.sources.goToSources')}
<ArrowRight aria-hidden="true" className="size-3" />
</Link>
</Button>
<Button
type="button"
variant="outline-primary"
shape="pill"
onClick={onUploadClick}
className="w-auto self-start"
>
{t('settings.sources.uploadNew')}
</Button>
+1 -1
View File
@@ -593,7 +593,7 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
{searchQuery && (
<div className="border-border bg-popover text-popover-foreground absolute top-full right-0 left-0 z-20 mt-1 w-full overflow-hidden rounded-xl border shadow-md">
<CommandList className="max-h-[calc(100vh-200px)] overscroll-contain">
<CommandList className="max-h-[calc(100dvh-200px)] overscroll-contain">
{searchResults.length === 0 ? (
<CommandEmpty>{t('settings.sources.noResults')}</CommandEmpty>
) : (
+3 -1
View File
@@ -42,8 +42,10 @@ const buttonVariants = cva(
// variant below sets the row's own padding and radius.
'sidebar-item':
'text-foreground hover:bg-sidebar-accent aria-[current=page]:bg-sidebar-accent',
// A field, so it gives way in a row like Input and SelectTrigger do
// (the base is shrink-0), keeping a neighbour's edit button in view.
combobox:
'border border-input bg-card font-normal shadow-xs hover:bg-accent data-placeholder:text-muted-foreground',
'min-w-0 shrink border border-input bg-card font-normal shadow-xs hover:bg-accent data-placeholder:text-muted-foreground',
// Underline tabs (FilePicker's drives, the agent page sub-nav). Mark the
// current tab with data-active; the 2px border is always there so the
// row height doesn't move. The compound variant squares the corners.
@@ -15,12 +15,12 @@ describe('LoadingState', () => {
expect(html.match(/role="status"/g)).toHaveLength(1);
});
it('uses py-10 for block and h-screen for screen', () => {
it('uses py-10 for block and the visible viewport (h-dvh) for screen', () => {
expect(renderToStaticMarkup(<LoadingState fill="block" />)).toContain(
'py-10',
);
expect(renderToStaticMarkup(<LoadingState fill="screen" />)).toContain(
'h-screen',
'h-dvh',
);
});
+1 -1
View File
@@ -9,7 +9,7 @@ import { Spinner, type SpinnerProps } from './spinner';
// section with none takes `block`.
const FILL_CLASSES = {
parent: 'h-full',
screen: 'h-screen',
screen: 'h-dvh',
block: 'py-10',
} as const;
+1 -1
View File
@@ -199,7 +199,7 @@ describe('Modal mobile sheet', () => {
expect.arrayContaining([
'bg-card',
'rounded-t-2xl',
'max-h-[90vh]',
'max-h-sheet',
'pb-safe',
'gap-3',
'px-4',
+1 -1
View File
@@ -43,7 +43,7 @@ describe('SheetContent side="bottom"', () => {
expect.arrayContaining([
'bg-card',
'rounded-t-2xl',
'max-h-[90vh]',
'max-h-sheet',
'pb-safe-0',
]),
);
+5 -4
View File
@@ -45,13 +45,13 @@ function SheetOverlay({
}
/**
* The phone bottom-sheet shape: card fill, 16px top corners, capped at 90% of
* the viewport. Shared with Modal's `mobileVariant="sheet"` so every bottom
* The phone bottom-sheet shape: card fill, 16px top corners, capped by
* `max-h-sheet` so the scrim above it stays tappable. Shared with Modal's `mobileVariant="sheet"` so every bottom
* sheet looks the same; the caller adds its own bottom padding (`pb-safe` or
* `pb-safe-0`).
*/
const sheetBottomShape =
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto max-h-[90vh] rounded-t-2xl bg-card';
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto max-h-sheet rounded-t-2xl bg-card';
/** The grab bar at the top of a bottom sheet, 8px below its edge. */
function SheetHandle({ className, ...props }: React.ComponentProps<'div'>) {
@@ -91,7 +91,8 @@ function SheetContent({
children,
side = 'right',
handle = false,
// A bottom sheet with a grab handle closes by drag or scrim, not an X.
// A bottom sheet with a grab handle closes by its scrim, not an X (the
// handle is only a cue; it doesn't drag).
showCloseButton = !handle,
title,
...props
+6 -3
View File
@@ -126,10 +126,13 @@ export default function AnswerFlow({
);
})}
{message && (
<div className="flex max-w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="flex w-full min-w-0 flex-col">
{/* ``ml-6`` is the answer's text column: step labels sit at the same
offset, with their icons in the gutter to its left. */}
<div className="animate-in fade-in slide-in-from-bottom-1.5 my-2 mr-5 ml-6 flex max-w-full flex-col duration-260 ease-out motion-reduce:animate-none">
offset, with their icons in the gutter to its left. Stretched,
not ``self-start max-w-full``: a shrink-to-fit box sizes to its
longest code line, and ``max-w-full`` caps it at 100% before the
margins land on top, so the chat scrolled sideways on a phone. */}
<div className="animate-in fade-in slide-in-from-bottom-1.5 my-2 mr-5 ml-6 flex min-w-0 flex-col duration-260 ease-out motion-reduce:animate-none">
<MarkdownAnswer
content={message}
isStreaming={isStreaming}
+28 -1
View File
@@ -13,6 +13,7 @@ import {
Plus,
Search,
Settings,
SquarePen,
Bot,
ChevronDown,
ChevronRight,
@@ -701,6 +702,30 @@ export default function DesignSystem() {
</Button>
</div>
</Example>
<Example
title="Truncating a name in a Button"
code='<Button className="min-w-0 shrink"><Avatar /><span className="truncate" title={name}>{name}</span><ChevronDown /></Button>'
>
<div className="flex w-64 items-center gap-1 rounded-md border p-1">
<div className="flex min-w-0 flex-1">
<Button variant="ghost" size="sm" className="min-w-0 shrink">
<span
className="truncate"
title="Explain the difference between agents and workflows"
>
Explain the difference between agents and workflows
</span>
<ChevronDown className="text-muted-foreground" aria-hidden />
</Button>
</div>
<IconButton
label="New chat"
variant="ghost-muted"
size="icon"
icon={SquarePen}
/>
</div>
</Example>
<Example
title="Combobox trigger"
code='<Button variant="combobox" role="combobox" data-placeholder={value ? undefined : ""}>'
@@ -2605,7 +2630,9 @@ export default function DesignSystem() {
<SheetTitle>Tools</SheetTitle>
<SheetDescription>
The phone picker shape: card fill, rounded top, grab
handle, clear of the home indicator.
handle, clear of the home indicator. max-h-sheet caps it
below the visible screen, so the scrim above can always be
tapped to close it.
</SheetDescription>
</SheetHeader>
</SheetContent>
+10
View File
@@ -28,6 +28,16 @@
padding-bottom: env(safe-area-inset-bottom);
}
/* The bottom-sheet cap. vh is the viewport with the toolbars hidden on iOS
Safari, so a 90vh sheet slides under the URL bar and covers its scrim.
dvh follows the visible area; less the top inset (the notch in an installed
PWA) and a 3rem strip, so the scrim above the sheet can always be tapped.
svh is the fallback where dvh isn't supported. */
@utility max-h-sheet {
max-height: 90svh;
max-height: calc(100dvh - env(safe-area-inset-top) - 3rem);
}
@utility no-scrollbar {
/* Chrome, Safari and Opera */
&::-webkit-scrollbar {
+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 上关注我们",
+3 -3
View File
@@ -122,7 +122,7 @@ export default function AgentDetailsModal({
</div>
{sharedToken ? (
<div className="flex flex-col gap-2">
<p className="text-foreground inline text-sm leading-normal font-medium break-all">
<p className="text-foreground inline text-sm leading-normal font-medium wrap-anywhere">
<a
href={`${baseURL}/shared/agent/${sharedToken}`}
target="_blank"
@@ -173,7 +173,7 @@ export default function AgentDetailsModal({
{apiKey ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className="text-foreground text-sm leading-normal font-medium break-all">
<div className="text-foreground text-sm leading-normal font-medium wrap-anywhere">
{apiKey}
{!apiKey.includes('...') && (
<CopyButton
@@ -232,7 +232,7 @@ export default function AgentDetailsModal({
</div>
{webhookUrl ? (
<div className="flex flex-col gap-2">
<p className="text-foreground text-sm leading-normal font-medium break-all">
<p className="text-foreground text-sm leading-normal font-medium wrap-anywhere">
<a href={webhookUrl} target="_blank" rel="noreferrer">
{webhookUrl}
</a>
@@ -0,0 +1,232 @@
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('lets a long title shrink and truncate instead of running under the actions', () => {
const long =
'Explain the difference between agents and workflows in detail';
render({ title: long, conversationId: 'c1', onNewChat: () => {} });
// Button's base is shrink-0, so min-w-0 alone never lets it narrow.
const trigger = container.querySelector('[data-testid="mobile-title"]')!;
const classes = trigger.className.split(' ');
expect(classes).toEqual(expect.arrayContaining(['min-w-0', 'shrink']));
expect(classes).not.toContain('shrink-0');
const name = trigger.querySelector('span.truncate')!;
expect(name.textContent).toBe(long);
expect(name.getAttribute('title')).toBe(long);
});
it('truncates a long plain-text title too', () => {
const long = 'A shared agent with a very long name that cannot fit';
render({ title: long, agentImage: '', onNewChat: () => {} });
const name = container.querySelector(
'[data-testid="mobile-title"] span.truncate',
)!;
expect(name.getAttribute('title')).toBe(long);
});
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();
});
it('keeps the menu on a chat or owned agent with an empty name', () => {
render({
title: '',
conversationId: 'c1',
editAgentPath: '/agents/edit/a1',
onRename: () => {},
onDelete: () => {},
});
const title = container.querySelector('[data-testid="mobile-title"]')!;
expect(title.tagName).toBe('BUTTON');
expect(title.textContent).toContain('newChat');
openMenu(title);
expect(menuItem('navigation.editAgent')).toBeDefined();
expect(menuItem('convTile.rename')).toBeDefined();
});
it('drops a rename in progress when the conversation changes', () => {
const onRename = vi.fn();
const props = { title: 'Router drops', onRename, onDelete: () => {} };
render({ ...props, conversationId: 'c1' });
openMenu(container.querySelector('[data-testid="mobile-title"]')!);
act(() => menuItem('convTile.rename')!.click());
expect(container.querySelector('input')).not.toBeNull();
render({ ...props, title: 'Other chat', conversationId: 'c2' });
expect(container.querySelector('input')).toBeNull();
expect(button('convTile.save')).toBeNull();
expect(onRename).not.toHaveBeenCalled();
});
it('closes an open share when the conversation changes', () => {
const props = {
title: 'Router drops',
onRename: () => {},
onDelete: () => {},
};
render({ ...props, conversationId: 'c1' });
openMenu(container.querySelector('[data-testid="mobile-title"]')!);
act(() => menuItem('convTile.share')!.click());
expect(
document.querySelector('[data-testid="share-modal"]'),
).not.toBeNull();
render({ ...props, conversationId: 'c2' });
expect(document.querySelector('[data-testid="share-modal"]')).toBeNull();
});
});
+286
View File
@@ -0,0 +1,286 @@
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');
// A rename, share or delete belongs to the chat it started on. The bar
// stays mounted across chats, so drop any that is open when the chat changes.
const [actionsFor, setActionsFor] = useState(conversationId);
if (actionsFor !== conversationId) {
setActionsFor(conversationId);
setIsEditing(false);
setIsShareOpen(false);
setDeleteModalState('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 (actions.length === 0) {
if (!title) return null;
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={title}>
{title}
</span>
</span>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
data-testid="mobile-title"
// Button is shrink-0; shrink lets it narrow to its slot so the
// name truncates instead of running under New Chat.
className="min-w-0 shrink"
>
{agentMark}
<span className="truncate" title={title || undefined}>
{title || t('newChat')}
</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}
/>
)}
</>
);
}
+2 -2
View File
@@ -250,13 +250,13 @@ function LogsTable({
}, []);
return (
<div className="border-border bg-card h-[55vh] w-full overflow-hidden rounded-xl border font-mono">
<div className="border-border bg-card h-[55svh] w-full overflow-hidden rounded-xl border font-mono">
<div className="bg-muted flex h-8 flex-col items-start justify-center">
<p className="text-muted-foreground px-3 text-xs">
{tableHeader ? tableHeader : t('settings.logs.tableHeader')}
</p>
</div>
<div className="relative flex h-[51vh] grow flex-col items-start gap-2 overflow-y-auto overscroll-contain bg-transparent p-4">
<div className="relative flex h-[51svh] grow flex-col items-start gap-2 overflow-y-auto overscroll-contain bg-transparent p-4">
{!loading && logs.length === 0 && (
<EmptyState
size="xs"
+1 -1
View File
@@ -286,7 +286,7 @@ export default function Prompts({
aria-label={titleAs === 'heading' ? titleText : undefined}
data-placeholder={selectedPrompt?.name ? undefined : ''}
className={cn(
'w-full min-w-0 justify-between',
'w-full justify-between',
titleAs === 'row' && 'sm:w-56',
)}
>
+1 -1
View File
@@ -387,7 +387,7 @@ export default function RemoteDeviceConfig({ tool, handleGoBack }: Props) {
key={entry.id}
className="bg-muted flex flex-col gap-1 rounded-md px-3 py-2 text-xs"
>
<code className="text-foreground block font-mono break-all whitespace-pre-wrap">
<code className="text-foreground block font-mono wrap-anywhere whitespace-pre-wrap">
{entry.command}
</code>
<div className="text-muted-foreground flex flex-wrap gap-3">