diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index 75cce079..6e5ca8a7 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -746,6 +746,15 @@ 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` / `w-full` when it should fill the row) and put the name
+in the `truncate` span; its icons stay `shrink-0`.
+
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".
@@ -822,14 +831,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`: on
+iOS Safari it is the viewport with the toolbars hidden, so a `90vh` sheet slides
+under the 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
@@ -850,6 +863,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[]`
diff --git a/frontend/src/components/SourcesPopoverFooter.test.tsx b/frontend/src/components/SourcesPopoverFooter.test.tsx
new file mode 100644
index 00000000..8e5f6aae
--- /dev/null
+++ b/frontend/src/components/SourcesPopoverFooter.test.tsx
@@ -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(
+
+
+ ,
+ ),
+ );
+ 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();
+ });
+});
diff --git a/frontend/src/components/SourcesPopoverFooter.tsx b/frontend/src/components/SourcesPopoverFooter.tsx
index 906ac6b0..0c7a3dcb 100644
--- a/frontend/src/components/SourcesPopoverFooter.tsx
+++ b/frontend/src/components/SourcesPopoverFooter.tsx
@@ -18,21 +18,20 @@ export default function SourcesPopoverFooter({
const { t } = useTranslation();
return (
-
-
- {t('settings.sources.goToSources')}
-
-
+ // One row when it fits (link left, upload right); on a narrow sheet or a
+ // long locale the button wraps under the link.
+
+
diff --git a/frontend/src/components/ui/modal.test.tsx b/frontend/src/components/ui/modal.test.tsx
index 0912eb97..7ba01da9 100644
--- a/frontend/src/components/ui/modal.test.tsx
+++ b/frontend/src/components/ui/modal.test.tsx
@@ -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',
diff --git a/frontend/src/components/ui/sheet.test.tsx b/frontend/src/components/ui/sheet.test.tsx
index de8c0ac5..f109115d 100644
--- a/frontend/src/components/ui/sheet.test.tsx
+++ b/frontend/src/components/ui/sheet.test.tsx
@@ -43,7 +43,7 @@ describe('SheetContent side="bottom"', () => {
expect.arrayContaining([
'bg-card',
'rounded-t-2xl',
- 'max-h-[90vh]',
+ 'max-h-sheet',
'pb-safe-0',
]),
);
diff --git a/frontend/src/components/ui/sheet.tsx b/frontend/src/components/ui/sheet.tsx
index 48416ea9..8869a495 100644
--- a/frontend/src/components/ui/sheet.tsx
+++ b/frontend/src/components/ui/sheet.tsx
@@ -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
diff --git a/frontend/src/design/DesignSystem.tsx b/frontend/src/design/DesignSystem.tsx
index 66f5513d..50910fad 100644
--- a/frontend/src/design/DesignSystem.tsx
+++ b/frontend/src/design/DesignSystem.tsx
@@ -13,6 +13,7 @@ import {
Plus,
Search,
Settings,
+ SquarePen,
Bot,
ChevronDown,
ChevronRight,
@@ -701,6 +702,30 @@ export default function DesignSystem() {
+
+
+
+
+
+
+
+ Tools
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.
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 9c2d052f..2c221f3a 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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 {
diff --git a/frontend/src/navigation/MobileTopBar.test.tsx b/frontend/src/navigation/MobileTopBar.test.tsx
index 0d4049dd..37733702 100644
--- a/frontend/src/navigation/MobileTopBar.test.tsx
+++ b/frontend/src/navigation/MobileTopBar.test.tsx
@@ -90,6 +90,31 @@ describe('MobileTopBar', () => {
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',
diff --git a/frontend/src/navigation/MobileTopBar.tsx b/frontend/src/navigation/MobileTopBar.tsx
index 3d783f3c..6018d302 100644
--- a/frontend/src/navigation/MobileTopBar.tsx
+++ b/frontend/src/navigation/MobileTopBar.tsx
@@ -184,7 +184,9 @@ export default function MobileTopBar({
className="text-foreground flex min-w-0 items-center gap-1.5 px-2 text-sm font-medium"
>
{agentMark}
- {title}
+
+ {title}
+
);
}
@@ -196,10 +198,14 @@ export default function MobileTopBar({
variant="ghost"
size="sm"
data-testid="mobile-title"
- className="min-w-0"
+ // 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}
- {title}
+
+ {title}
+