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 (
-      
+
DocsGPT -
+
- +
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 (