diff --git a/.github/pr-screenshots/chats-header-search/activity-dark.png b/.github/pr-screenshots/chats-header-search/activity-dark.png new file mode 100644 index 0000000000..e09dfed2db Binary files /dev/null and b/.github/pr-screenshots/chats-header-search/activity-dark.png differ diff --git a/.github/pr-screenshots/chats-header-search/activity-light.png b/.github/pr-screenshots/chats-header-search/activity-light.png new file mode 100644 index 0000000000..fe40748af6 Binary files /dev/null and b/.github/pr-screenshots/chats-header-search/activity-light.png differ diff --git a/.github/pr-screenshots/chats-header-search/chats-dark.png b/.github/pr-screenshots/chats-header-search/chats-dark.png new file mode 100644 index 0000000000..8855e4f7ca Binary files /dev/null and b/.github/pr-screenshots/chats-header-search/chats-dark.png differ diff --git a/.github/pr-screenshots/chats-header-search/chats-light.png b/.github/pr-screenshots/chats-header-search/chats-light.png new file mode 100644 index 0000000000..afd38837e1 Binary files /dev/null and b/.github/pr-screenshots/chats-header-search/chats-light.png differ diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index 6cc1438d09..74aaf7882f 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -43,9 +43,9 @@ Build a ⋯ menu with `ActionMenuButton` (`src/ui/primitives/action-menu-button` ## Activity query controls - Activity has two header entries: **Filter and sort** and **Search Activity**. Sorting is part of the same options sheet as category, unread-only, Bot and conversation filters. -- iOS uses native navigation toolbar buttons: the system supplies Liquid Glass on iOS 26 and its supported appearance on earlier versions. Active search uses a native input in a measured floating surface outside the fixed-height navigation bar, so accessibility text can grow without clipping. Android uses opaque Material-style icon actions and its native text input. +- iOS uses native navigation toolbar buttons: the system supplies Liquid Glass on iOS 26 and its supported appearance on earlier versions. Active search replaces the title, Filter and Search buttons with the same header search field as Chats, in the measured header row, so accessibility text can grow without clipping. Android uses opaque Material-style icon actions and its native text input. - The iOS options sheet uses system page-sheet presentation with swipe dismissal, inset grouped rows and trailing checkmarks. Android uses the modal bottom-sheet surface, leading radio indicators and system Back dismissal. Unread-only uses a native switch on each platform. Long option lists virtualize and scroll; footer actions remain reachable. -- Options apply immediately and remain selected after swipe dismissal or Android Back; there are no Cancel/Done actions. Reset all immediately restores default ordering and the Needs you tab, with all Bots/conversations and unread-only off. Search is independent of this reset. The iOS search surface reserves equal outer gutters; iOS search keeps its built-in clear control; tapping blank list space dismisses search without a second close button and preserves the query for reopening. The focused Activity screen owns status-bar contrast for its current appearance. +- Options apply immediately and remain selected after swipe dismissal or Android Back; there are no Cancel/Done actions. Reset all immediately restores default ordering and the Needs you tab, with all Bots/conversations and unread-only off. Search is independent of this reset. The search field keeps the header's outer gutters; iOS search keeps its built-in clear control. Tapping blank list space or ending editing returns the header to its title and buttons and preserves the query for reopening, with the Search button tinted while a query applies, so Filter and sort stay reachable during a search. The close button (iOS) or back arrow and system Back (Android) clears the query. The focused Activity screen owns status-bar contrast for its current appearance. References: [Apple toolbars](https://developer.apple.com/design/human-interface-guidelines/toolbars), [Apple sheets](https://developer.apple.com/design/human-interface-guidelines/sheets), [Material bottom sheets](https://m3.material.io/components/bottom-sheets/guidelines), [Android radio selection](https://developer.android.com/develop/ui/compose/components/radio-button). @@ -94,8 +94,8 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - **Chats** shows the current Host: - Conversation rows keep the title and timestamp on one horizontal line at every system text size. The title takes the remaining width and truncates with tail ellipsis; the timestamp keeps its own single-line slot. Preview/status and count stay on a second line. A conversation without a timestamp does not reserve an empty metadata line. Preserve native font scaling and the complete accessible title/preview. - The header has a compact Host title on the left and a Settings gear on the right, on both iOS and Android. This row stays horizontal at every system text size: the Host chip shrinks, its name occupies one line with tail ellipsis, and the Settings target stays at the trailing edge. Keep native font scaling and the full accessible Host name; both targets retain the platform minimum touch size. Other Host-name presentations may wrap. The Host picker stays mounted between visits; its translation follows a critically damped UI-thread spring and its scrim is clamped. Hidden contents leave the accessibility tree. - - **+** sits at the right of the channel search field. It opens the native **New PersonaBot** / **New Channel** menu and uses the app's create flow, or the full client where the app has none yet. - - A **search field** floats just above the bottom navigation island, within reach of the thumb, as iOS 26 places search. It is a capsule on the shared navigation glass (`DrawerMaterial`; opaque raised on older iOS, Reduce Transparency and Android) inset like the island, rides above the keyboard while typing, and hides and returns with the navigation on scroll. While it is focused or holds a query the navigation stays visible, and a circular close button beside it (the island's glass circle) clears the query and dismisses the keyboard. The field is a native UIKit text field with the system clear button, not a SwiftUI field, which has none. The list reserves the field's height under its last row. There are no shortcut cards: Activity already gathers what needs the Human. + - **+** is the circle beside the bottom navigation island. It opens the native **New PersonaBot** / **New Channel** menu and uses the app's create flow, or the full client where the app has none yet. + - **Search** is a magnifying-glass button just left of the Settings gear. Tapping it turns the whole header row into the search field and hides the Host title and Settings, as a Material top app bar opens its search and as iOS expands a toolbar search from its button. The field is the shared navigation glass capsule (`DrawerMaterial`; opaque raised on older iOS, Reduce Transparency and Android) and focuses at once. iOS closes it with the native circular close button at the trailing end and keeps the system clear button inside the field. Android leads with a back arrow, adds its own clear button while there is a query, and system Back closes search. Closing clears the query and restores the title and Settings. Ending editing with an empty query closes search; with a query, the field stays in the header so the filtered list keeps showing what it matches. Navigation stays visible while the field is focused or holds a query. Nothing floats above the bottom navigation, and the list reserves only the navigation's height. There are no shortcut cards: Activity already gathers what needs the Human. - One list of PersonaBot DMs and Channels together, grouped by the Host's **Channel sections** as on the desktop sidebar: - Sections come in the desktop's order. Ungrouped DMs and Channels sit between them in the desktop's top-level order. - A section header shows its name and Channel count and collapses or expands on tap. A collapsed section still shows a "Needs you" mark when something inside needs the Human. The collapse state is kept on the phone, per Host. diff --git a/apps/mobile/app/(tabs)/(home)/index.tsx b/apps/mobile/app/(tabs)/(home)/index.tsx index f26015d080..891e6a312d 100644 --- a/apps/mobile/app/(tabs)/(home)/index.tsx +++ b/apps/mobile/app/(tabs)/(home)/index.tsx @@ -23,9 +23,7 @@ import { conversations, searchKey, type Conversation } from '../../../src/ui/cha import { CloudConversationRow } from '../../../src/ui/chat/cloud-conversation-row'; import { useConversationFilter } from '../../../src/host/native-conversation-filter'; import { ConnectionBanner } from '../../../src/ui/connection-banner'; -import { useDesignTheme } from '../../../src/ui/design/theme'; import { space } from '../../../src/ui/design/tokens'; -import { BottomSearch, bottomSearchHeight } from '../../../src/ui/shell/bottom-search'; import { EnvironmentPager } from '../../../src/ui/shell/environment-pager'; import { conversationEnvironments, @@ -34,6 +32,7 @@ import { } from '../../../src/host/conversation-environments'; import { shownHostName } from '../../../src/ui/design/host-look'; import { Text } from '../../../src/ui/primitives/text'; +import { HeaderSearch, HeaderSearchButton } from '../../../src/ui/shell/header-search'; import { HomeHeader } from '../../../src/ui/shell/home-header'; import { HostDrawerEdge } from '../../../src/ui/shell/host-drawer'; import { SettingsButton } from '../../../src/ui/shell/settings-button'; @@ -101,10 +100,10 @@ const HomeContent = memo(function HomeContent({ ); const [query, setQuery] = useState(''); const [searching, setSearching] = useState(false); - const onScroll = useChromeScroll(true, searching || query !== ''); + const [searchFocused, setSearchFocused] = useState(false); + const onScroll = useChromeScroll(true, searchFocused || query !== ''); const chrome = useScrollChrome(); - const theme = useDesignTheme(); - const bottom = useFloatingTabInset() + bottomSearchHeight(theme.touch); + const bottom = useFloatingTabInset(); const look = useHostLook(saved?.hostId); const cloud = useCloudSnapshot(); const directory = useCloudConversations(); @@ -266,7 +265,35 @@ const HomeContent = memo(function HomeContent({ <> - router.push('/settings')} />}> + { + setSearchFocused(next); + if (!next && query === '') setSearching(false); + }} + onClose={() => { + setSearchFocused(false); + setSearching(false); + }} + /> + ) : undefined + } + actions={ + <> + setSearching(true)} + /> + router.push('/settings')} /> + + } + > - ); diff --git a/apps/mobile/src/ui/shell/activity-toolbar-base.tsx b/apps/mobile/src/ui/shell/activity-toolbar-base.tsx index 5a33232e81..179ece08ea 100644 --- a/apps/mobile/src/ui/shell/activity-toolbar-base.tsx +++ b/apps/mobile/src/ui/shell/activity-toolbar-base.tsx @@ -1,14 +1,14 @@ import { useImperativeHandle, useState, type ReactNode, type Ref } from 'react'; import { Stack } from 'expo-router'; -import { Keyboard, Platform, Pressable, View } from 'react-native'; +import { Keyboard, Pressable, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useDesignTheme } from '../design/theme'; import { radius, space } from '../design/tokens'; -import { SearchField } from '../primitives/search-field'; import { Text } from '../primitives/text'; import { t } from '../strings'; -import { ActivityFilterIcon, ActivitySearchIcon, CloseIcon } from './icons'; +import { HeaderSearch } from './header-search'; +import { ActivityFilterIcon, ActivitySearchIcon } from './icons'; export type ActivityToolbarHandle = { dismissSearch(): void }; @@ -70,68 +70,60 @@ export function ActivityToolbar({ paddingHorizontal: space.x4, }} > - - {t.activity} - - {!unavailable ? ( - - - - ) : null} - {!unavailable && !searching ? ( - { - onSearchFocus(true); - setSearching(true); + {!unavailable && searching ? ( + onSearch(value.slice(0, 200))} + onFocusChange={(focused) => { + onSearchFocus(focused); + if (!focused) setSearching(false); }} - > - - - ) : null} - - {!unavailable && searching ? ( - onSearch('')} - style={{ - width: theme.touch, - minHeight: theme.touch, - alignItems: 'center', - justifyContent: 'center', + onClose={() => { + onSearchFocus(false); + setSearching(false); + }} + /> + ) : ( + <> + + {t.activity} + + {!unavailable ? ( + + + + ) : null} + {!unavailable ? ( + { + onSearchFocus(true); + setSearching(true); }} > - - - ) : null - } - autoFocus - onBlur={() => { - setSearching(false); - onSearchFocus(false); - }} - value={search} - inset={space.x4} - placeholder={t.activitySearch} - onChangeText={(value) => onSearch(value.slice(0, 200))} - /> - ) : null} + + + ) : null} + + )} + ); diff --git a/apps/mobile/src/ui/shell/bottom-search.tsx b/apps/mobile/src/ui/shell/bottom-search.tsx deleted file mode 100644 index 423565417e..0000000000 --- a/apps/mobile/src/ui/shell/bottom-search.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useState } from 'react'; -import { Keyboard, View } from 'react-native'; -import { KeyboardStickyView } from 'react-native-keyboard-controller'; -import Animated, { useAnimatedStyle } from 'react-native-reanimated'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { useDesignTheme } from '../design/theme'; -import { space } from '../design/tokens'; -import { SearchField } from '../primitives/search-field'; -import { useFloatingTabInset } from './floating-tab-inset'; -import { NavigationAction } from './navigation-action'; -import { useChromeHidden, useChromeProgress } from './scroll-chrome'; -import { t } from '../strings'; - -const GAP = space.x3; - -export function bottomSearchHeight(touch: number): number { - return touch + GAP; -} - -export function BottomSearch({ - value, - placeholder, - onChangeText, - onFocusChange, -}: { - value: string; - placeholder: string; - onChangeText(text: string): void; - onFocusChange(focused: boolean): void; -}) { - const theme = useDesignTheme(); - const [focused, setFocused] = useState(false); - const focus = (next: boolean) => { - setFocused(next); - onFocusChange(next); - }; - const insets = useSafeAreaInsets(); - const reserved = useFloatingTabInset(); - const lift = reserved - space.x5 + GAP; - const progress = useChromeProgress(); - const hidden = useChromeHidden(); - const style = useAnimatedStyle(() => ({ - transform: [{ translateY: (lift + theme.touch) * progress.value }], - opacity: 1 - progress.value, - })); - return ( - - - - focus(true)} - onBlur={() => focus(false)} - trailing={ - focused || value !== '' ? ( - { - onChangeText(''); - Keyboard.dismiss(); - }} - /> - ) : null - } - /> - - - - ); -} diff --git a/apps/mobile/src/ui/shell/header-search.tsx b/apps/mobile/src/ui/shell/header-search.tsx new file mode 100644 index 0000000000..cfb12c0f84 --- /dev/null +++ b/apps/mobile/src/ui/shell/header-search.tsx @@ -0,0 +1,122 @@ +import { useRef, type ReactNode } from 'react'; +import { BackHandler, Keyboard, Platform, Pressable, View, type ViewInstance } from 'react-native'; + +import { useDesignTheme } from '../design/theme'; +import { radius } from '../design/tokens'; +import { SearchField } from '../primitives/search-field'; +import { useMounted } from '../mounted'; +import { t } from '../strings'; +import { ActivitySearchIcon, ChevronLeftIcon, CloseIcon } from './icons'; +import { NavigationAction } from './navigation-action'; + +export function HeaderSearchButton({ + label, + testID, + onPress, +}: { + label: string; + testID?: string; + onPress(): void; +}) { + const theme = useDesignTheme(); + return ( + + + + ); +} + +export function HeaderSearch({ + value, + placeholder, + onChangeText, + onFocusChange, + onClose, +}: { + value: string; + placeholder: string; + onChangeText(text: string): void; + onFocusChange(focused: boolean): void; + onClose(): void; +}) { + const theme = useDesignTheme(); + const close = () => { + Keyboard.dismiss(); + onChangeText(''); + onClose(); + }; + const latest = useRef(close); + latest.current = close; + const backHandler = useMounted(() => { + if (Platform.OS !== 'android') return; + const back = BackHandler.addEventListener('hardwareBackPress', () => { + latest.current(); + return true; + }); + return () => back.remove(); + }, []); + const android = Platform.OS === 'android'; + return ( + <> + {android ? ( + + + + ) : null} + + onFocusChange(true)} + onBlur={() => onFocusChange(false)} + trailing={ + !android ? ( + + ) : value !== '' ? ( + onChangeText('')}> + + + ) : null + } + /> + + + ); +} + +function HeaderSearchAction({ + label, + testID, + onPress, + children, +}: { + label: string; + testID?: string | undefined; + onPress(): void; + children: ReactNode; +}) { + const theme = useDesignTheme(); + return ( + ({ + width: theme.touch, + height: theme.touch, + borderRadius: radius.round, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: pressed ? theme.layer.raised.pressed : theme.layer.raised.fill, + })} + > + {children} + + ); +} diff --git a/apps/mobile/src/ui/shell/home-header.tsx b/apps/mobile/src/ui/shell/home-header.tsx index 3e4459f9b8..a369e413c8 100644 --- a/apps/mobile/src/ui/shell/home-header.tsx +++ b/apps/mobile/src/ui/shell/home-header.tsx @@ -11,7 +11,15 @@ import { t } from '../strings'; import { ChevronDownIcon } from './icons'; import { focusOn, hostDrawer, openHostDrawer, useHostDrawerOpen } from './host-drawer'; -export function HomeHeader({ actions, children }: { actions?: ReactNode; children: ReactNode }) { +export function HomeHeader({ + actions, + search, + children, +}: { + actions?: ReactNode; + search?: ReactNode; + children: ReactNode; +}) { const theme = useDesignTheme(); const focused = useIsFocused(); const drawerOpen = useHostDrawerOpen(); @@ -33,28 +41,32 @@ export function HomeHeader({ actions, children }: { actions?: ReactNode; childre paddingBottom: space.x2, }} > - ({ - flex: 1, - minWidth: 0, - minHeight: theme.touch, - flexDirection: 'row', - alignItems: 'center', - gap: space.x2, - opacity: pressed ? 0.7 : 1, - })} - > - - {t.chats} - - - - {actions} + {search ?? ( + <> + ({ + flex: 1, + minWidth: 0, + minHeight: theme.touch, + flexDirection: 'row', + alignItems: 'center', + gap: space.x2, + opacity: pressed ? 0.7 : 1, + })} + > + + {t.chats} + + + + {actions} + + )} {children} diff --git a/apps/mobile/src/ui/strings.ts b/apps/mobile/src/ui/strings.ts index 7fe49dbe5b..d3b239f3a3 100644 --- a/apps/mobile/src/ui/strings.ts +++ b/apps/mobile/src/ui/strings.ts @@ -690,6 +690,7 @@ const en = { unreadCount: (count: number) => (count === 1 ? '1 unread message' : `${count} unread messages`), mutedUnread: (count: number) => count === 1 ? 'muted, 1 unread message' : `muted, ${count} unread messages`, + openSearch: 'Search', closeSearch: 'Close search', searchPlaceholder: 'Search Bots and Channels', searchNoMatch: (query: string) => `No Bot or Channel matches “${query}”.`, @@ -1742,6 +1743,7 @@ const zh: typeof en = { sectionNeedsYou: (count: number) => `${count} 个需要你`, unreadCount: (count: number) => `${count} 条未读消息`, mutedUnread: (count: number) => `已静音,${count} 条未读消息`, + openSearch: '搜索', closeSearch: '关闭搜索', searchPlaceholder: '搜索 Bot 和频道', searchNoMatch: (query: string) => `没有名称包含“${query}”的 Bot 或频道。`, diff --git a/apps/mobile/test/home-search.test.tsx b/apps/mobile/test/home-search.test.tsx index 0141f229b2..1b4111e7f5 100644 --- a/apps/mobile/test/home-search.test.tsx +++ b/apps/mobile/test/home-search.test.tsx @@ -108,7 +108,20 @@ vi.mock('../src/host/native-host-look', () => ({ })); vi.mock('../src/ui/chat/section-header', () => ({ SectionHeader: () => null })); vi.mock('../src/ui/shell/home-header', () => ({ - HomeHeader: ({ children }: { children: ReactNode }) => children, + HomeHeader: ({ + actions, + search, + children, + }: { + actions?: ReactNode; + search?: ReactNode; + children: ReactNode; + }) => ( + <> + {search ?? actions} + {children} + + ), })); vi.mock('../src/ui/shell/host-drawer', () => ({ HostDrawerEdge: ({ children }: { children: ReactNode }) => children, @@ -154,6 +167,8 @@ async function home(): Promise { } async function type(view: HTMLElement, text: string): Promise { + const open = view.querySelector('[data-testid="search-button"]'); + if (open) await act(async () => open.click()); const field = view.querySelector( `textarea[aria-label="${t.searchPlaceholder}"]`, )!; @@ -169,13 +184,18 @@ const rows = (view: HTMLElement) => .filter((node) => !node.closest('[hidden]') && !node.dataset.testid?.startsWith('pager-')) .filter( (node) => - ![t.cloudJoin.newOrJoin, t.allEnvironments, 'studio', t.closeSearch].includes( - node.getAttribute('aria-label') ?? '', - ), + ![ + t.cloudJoin.newOrJoin, + t.allEnvironments, + 'studio', + t.closeSearch, + t.openSearch, + t.settings, + ].includes(node.getAttribute('aria-label') ?? ''), ) .map((node) => node.getAttribute('aria-label')); -describe('Search above the bottom navigation', () => { +describe('Search in the Chats header', () => { it('keeps the Computer usable when Cloud local state cannot load', async () => { cloudState.ready = false; cloudState.error = 'STORAGE_UNAVAILABLE'; @@ -334,10 +354,32 @@ describe('Search above the bottom navigation', () => { await act(async () => view.querySelector(`button[aria-label="${t.closeSearch}"]`)!.click(), ); + expect(view.querySelector(`textarea[aria-label="${t.searchPlaceholder}"]`)).toBeNull(); + expect(view.querySelector(`button[aria-label="${t.settings}"]`)).not.toBeNull(); + expect(view.textContent).toContain('Launch team'); + await act(async () => + view.querySelector('[data-testid="search-button"]')!.click(), + ); + expect( + view.querySelector(`textarea[aria-label="${t.searchPlaceholder}"]`)! + .value, + ).toBe(''); + }); + it('replaces the title and Settings with the field, and keeps a query after editing ends', async () => { + const view = await home(); + expect(view.querySelector(`textarea[aria-label="${t.searchPlaceholder}"]`)).toBeNull(); + await type(view, 'ada'); + expect(view.querySelector(`button[aria-label="${t.settings}"]`)).toBeNull(); + expect(view.querySelector('[data-testid="search-button"]')).toBeNull(); const field = view.querySelector( `textarea[aria-label="${t.searchPlaceholder}"]`, )!; - expect(field.value).toBe(''); - expect(view.textContent).toContain('Launch team'); + await act(async () => field.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))); + expect(field.isConnected).toBe(true); + expect(rows(view)).toEqual(['Ada, Done']); + await type(view, ''); + await act(async () => field.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))); + expect(view.querySelector(`textarea[aria-label="${t.searchPlaceholder}"]`)).toBeNull(); + expect(view.querySelector(`button[aria-label="${t.settings}"]`)).not.toBeNull(); }); }); diff --git a/changes/2377-mobile-header-search.md b/changes/2377-mobile-header-search.md new file mode 100644 index 0000000000..35111b9551 --- /dev/null +++ b/changes/2377-mobile-header-search.md @@ -0,0 +1,11 @@ +--- +section: Changed +--- + +## en + +- Search in the mobile Chats list now opens from a button beside Settings and fills the header, instead of floating above the bottom navigation; Activity search opens in its header the same way ([#2377](https://github.com/BotHarness/DeepSeekBot/pull/2377)). + +## zh + +- 手机端对话列表的搜索改为从“设置”旁的按钮打开,并占满顶部栏,不再浮在底部导航上方;活动页的搜索也同样在顶部栏展开([#2377](https://github.com/BotHarness/DeepSeekBot/pull/2377))。