mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 06:17:23 +00:00
Merge pull request #2833 from arc53/ui-polish
This commit is contained in:
44 files changed
+913
-141
No files matched your search
+107
-8
@@ -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
|
||||
|
||||
|
||||
@@ -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': [
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('SheetContent side="bottom"', () => {
|
||||
expect.arrayContaining([
|
||||
'bg-card',
|
||||
'rounded-t-2xl',
|
||||
'max-h-[90vh]',
|
||||
'max-h-sheet',
|
||||
'pb-safe-0',
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 上关注我们",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user