diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 43b6e7bb..9ae44f23 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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 `
` 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
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index b39ad5ae..a0b01e8b 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -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': [
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 10167400..20f2ed11 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -64,7 +64,7 @@ function AuthWrapper({ children }: { children: React.ReactNode }) {
? t('auth.accountDisabled')
: t('auth.signInToContinue');
return (
-
+
-
+
-
+
diff --git a/frontend/src/Navigation.tsx b/frontend/src/Navigation.tsx
index d2e6146c..f576da40 100644
--- a/frontend/src/Navigation.tsx
+++ b/frontend/src/Navigation.tsx
@@ -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 && (
setNavOpen(false)}
/>
)}
@@ -426,7 +440,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
size="icon"
onClick={() => newChat()}
>
-
+
)}
@@ -528,7 +542,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
)
}
>
-
@@ -833,20 +847,24 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
-
-
- setNavOpen(true)}
- >
-
-
-
-
+ 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
+ }
+ />
+
{t('pageNotFound.title')}
{t('pageNotFound.message')}
diff --git a/frontend/src/admin/Admins.tsx b/frontend/src/admin/Admins.tsx
index 8e08fdea..296e47e9 100644
--- a/frontend/src/admin/Admins.tsx
+++ b/frontend/src/admin/Admins.tsx
@@ -81,7 +81,7 @@ export default function Admins() {
{admins.map((a) => (
-
+
{a.user_id}
diff --git a/frontend/src/admin/Quotas.tsx b/frontend/src/admin/Quotas.tsx
index 8b382454..e48d22ce 100644
--- a/frontend/src/admin/Quotas.tsx
+++ b/frontend/src/admin/Quotas.tsx
@@ -308,7 +308,7 @@ export default function Quotas() {
{userPolicies.map((policy) => (
-
+
{policy.subject_id}
{bucketPill(policy)}
diff --git a/frontend/src/admin/Usage.tsx b/frontend/src/admin/Usage.tsx
index f8799438..17faff09 100644
--- a/frontend/src/admin/Usage.tsx
+++ b/frontend/src/admin/Usage.tsx
@@ -224,7 +224,7 @@ export default function Usage() {
{topUsers.map((user) => (
-
+
-
-
+
+
{agent.name}
-
+
{agent.description}
{onEdit && (
-
+ <>
+ {/* A phone has no room for a long localised label beside the name. */}
+
+
+ >
)}
{hasSharedMetadata && (
diff --git a/frontend/src/agents/components/GuardrailEvents.tsx b/frontend/src/agents/components/GuardrailEvents.tsx
index bc9fc7d1..d1b5acf4 100644
--- a/frontend/src/agents/components/GuardrailEvents.tsx
+++ b/frontend/src/agents/components/GuardrailEvents.tsx
@@ -251,7 +251,7 @@ export default function GuardrailEvents({ agentId }: Props) {
-
+
{loading ? (
diff --git a/frontend/src/agents/workflow/WorkflowBuilder.tsx b/frontend/src/agents/workflow/WorkflowBuilder.tsx
index ee79e146..830170c3 100644
--- a/frontend/src/agents/workflow/WorkflowBuilder.tsx
+++ b/frontend/src/agents/workflow/WorkflowBuilder.tsx
@@ -1778,7 +1778,7 @@ function WorkflowBuilderInner() {
return (
<>
-
+
{canManageAgent ? (
@@ -2136,7 +2136,7 @@ function WorkflowBuilderInner() {
-
+
diff --git a/frontend/src/agents/workflow/components/MobileBlocker.tsx b/frontend/src/agents/workflow/components/MobileBlocker.tsx
index dec6aa56..e7058340 100644
--- a/frontend/src/agents/workflow/components/MobileBlocker.tsx
+++ b/frontend/src/agents/workflow/components/MobileBlocker.tsx
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
export default function MobileBlocker() {
const { t } = useTranslation();
return (
-
+
diff --git a/frontend/src/components/ActionButtons.tsx b/frontend/src/components/ActionButtons.tsx
index 92188f9e..42021c2a 100644
--- a/frontend/src/components/ActionButtons.tsx
+++ b/frontend/src/components/ActionButtons.tsx
@@ -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();
const conversationId = useSelector(selectConversationId);
const [isShareModalOpen, setShareModalState] = useState(false);
- const navigate = useNavigate();
- const newChat = () => {
- dispatch(setConversation([]));
- dispatch(
- updateConversationId({
- query: { conversationId: null },
- }),
- );
- navigate('/c/new');
- };
return (
- {showNewChat && (
-
-
-
- )}
-
{showShare && conversationId && (
<>
= ({