From 3380726e5a6c0a52f03e2ecbbced1445406cbc82 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:11:19 +0000 Subject: [PATCH 1/2] feat(mobile): float the Android chat header and mask both chat edges like iOS Android's chat header now floats over the messages with a round Back, the title chip as its own island and a round computer button. The drawn edge fade becomes a mask layer between the list and the floating chrome: nearly opaque under the composer and the header, then the light edge curve, in the chat's own background colour, with an explicit top-to-bottom gradient so it no longer renders reversed above the composer. The composer's + is a filled round button on the field's raised fill. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QxWnExdvT58a8MeXHJ7aRn --- apps/mobile/DESIGN.md | 6 +-- apps/mobile/src/ui/chat/channel-title.tsx | 3 +- apps/mobile/src/ui/chat/chat-title.tsx | 3 +- apps/mobile/src/ui/chat/composer-chrome.tsx | 4 +- .../src/ui/chat/conversation-screen.tsx | 53 ++++++++++++++----- apps/mobile/src/ui/chat/edge-fade.ts | 17 ++++++ apps/mobile/src/ui/chat/header-chrome.tsx | 38 ++++++++----- apps/mobile/src/ui/chat/native-header.ts | 7 +++ apps/mobile/src/ui/design/tokens.ts | 1 + apps/mobile/src/ui/open-full-client.tsx | 4 +- .../test/conversation-navigation.test.tsx | 3 ++ .../test/conversation-streaming-rows.test.tsx | 3 ++ apps/mobile/test/edge-fade.test.ts | 19 +++++++ 13 files changed, 130 insertions(+), 31 deletions(-) create mode 100644 apps/mobile/src/ui/chat/edge-fade.ts create mode 100644 apps/mobile/test/edge-fade.test.ts diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index c43648a255..28f1071885 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -159,10 +159,10 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - Header: the system navigation header of the root native stack (expo-router `Stack`), so back, the swipe back and the iOS 26 header transitions are the platform's own. It has no bar fill, title text, subtitle or state dot. Leading, from the left: Back, then right beside it the title chip (the Bot's avatar and name, leading-aligned, never centred), which opens the Bot profile. Trailing: one round button for the Bot's computer. Back is the native back button; a plain back control replaces it only when the chat is the first screen of the stack, and returns to Chats. - iOS: the header is transparent (`headerTransparent`) with no `headerStyle.backgroundColor`, so UIKit draws the bar; a clear colour would remove its material. The chip is a native bar item of type `custom` (`unstable_headerLeftItems`), and `headerTitle` is empty. The computer button, and the plain back control when the chat is the first screen, are native bar buttons with SF Symbols (`macwindow`, `chevron.left`), so UIKit tints them. The chip's text uses the system `label` colour, not `fg`, because the bar's glass picks its own light or dark look from what is under it and from Reduce Transparency; a fixed `fg` went white on a light grey item in dark mode with Reduce Transparency on. A zero-width `spacing` item separates the chip from Back so each gets its own glass shape: a round Back and a separate capsule. iOS 26 draws that Liquid Glass behind the bar items itself, so the items carry no `glassEffect` or fill of their own (that would draw a second, skinny capsule). The chip is capped so the computer button always fits. Before iOS 26 the bar uses the system chrome blur. - - Android: a Material top app bar, opaque on `base`, with no shadow: the navigation icon, then the chip at the start (`headerTitleAlign: 'left'`), then the computer action. Messages begin below it. + - Android: the header floats over the messages like iOS's (`headerTransparent`, no bar fill, no shadow), with the same three shapes as Grok's chat header: a round Back, the title chip at the start (`headerTitleAlign: 'left'`) as its own capsule island, and a round computer button. Each shape uses the opaque-iOS fallback of the header buttons (`headerGlassFill`: frosted `raised` fill with a `raised` hairline), so the chip reads as an island, not a label in a bar. Back is always this round button (it replaces the native navigation icon; system and predictive back still work). The list pads its own top by the header height, and messages scroll under the header through the drawn top fade (see below). Android has no system scroll edge effect: Material 3 only tints the top app bar on scroll, and the View fading edge (`fadingEdgeLength`) fades content linearly at the scroll view's own bounds, so it cannot keep what passes under the floating chrome hidden. - The chip holds, in one row, the Bot's avatar (28pt, round, no state ring) and its name in `body` strong; a group shows its mark and name. It is `theme.touch` tall with padding concentric with its corners. The Bot's state is read out in its accessibility label only. Pressing shows the `raised` pressed fill. Icons in the header are `fg`, never the Bot's accent. - iOS: messages run under the transparent header and the status bar, and UIKit's scroll edge effect (left on `automatic`) softens them there. The list uses `contentInsetAdjustmentBehavior="automatic"`, so UIKit, not a manual padding, keeps the header's height free above the oldest message at rest; the edge effect needs that automatic inset. The edge effect and React Native Screens both find the content scroll view through the first-subview chain of the screen, so the message list is the first thing in that chain: a default or colour chat background is the frame's own `backgroundColor`, not a view in front of the list. An image background still needs a view behind the list, and with one the soft edge does not reach the list. UIKit also adds the home-indicator safe area to the bottom inset while the list reaches the screen bottom (keyboard closed); the list cancels it with a negative `contentInset.bottom`, because the composer's height already includes that padding. Without this, scrolling to the very end left 34pt more space below the newest message than the resting position. There is no painted top gradient. A transparent header dimmed the whole message list on iOS 26 while the list was inverted (a scaleY flip); the list now runs top to bottom, which this header depends on. - - The list runs to the bottom of the screen, and the composer floats over it with no bar of its own; messages pass around and under its **+** button and field capsule. On iOS 26 with Reduce Transparency off both shapes are Liquid Glass, like iOS Messages; older iOS and Reduce Transparency give them an opaque `surface` fill with a `borderStrong` hairline, and Android gives the field the opaque `raised` fill, so on those nothing shows through where the Human types. The bottom of the screen fades out with the same light gradient over the composer and `edgeFade.bottom` above it. The list keeps that much space below the newest message, so at rest it sits just above the composer; scrolled up, messages pass behind the composer. Dragging the list dismisses the keyboard: interactively on iOS (the keyboard follows the finger once it reaches it), on drag on Android. On iOS 26 the bottom edge is UIKit's own scroll edge effect, like the top: the composer's container carries a `UIScrollEdgeElementContainerInteraction` for the message list (the local `scroll-edge-element` module; UIKit shapes the edge once when the interaction is added, so the container re-adds it whenever the composer's height or the keyboard changes), so UIKit softens messages under the composer the way it does under a system toolbar, and adapts it to Reduce Transparency itself. No fade is drawn there. A build without that module, older iOS and Android keep the drawn bottom fade over the composer's measured height (with the soft `EdgeMaterial` blur on iOS 26 with Reduce Transparency off). With the keyboard open the composer's pill sits `space.x3` above it: the keyboard already covers the home indicator, so the chat frame takes the composer's safe-area padding back out of the keyboard offset instead of counting it twice. + - The list runs to the bottom of the screen, and the composer floats over it with no bar of its own; messages pass around and under its **+** button and field capsule. On iOS 26 with Reduce Transparency off both shapes are Liquid Glass, like iOS Messages; older iOS and Reduce Transparency give them an opaque `surface` fill with a `borderStrong` hairline, and Android gives the field the opaque `raised` fill, so on those nothing shows through where the Human types. The bottom of the screen fades out with the same light gradient over the composer and `edgeFade.bottom` above it. The list keeps that much space below the newest message, so at rest it sits just above the composer; scrolled up, messages pass behind the composer. Dragging the list dismisses the keyboard: interactively on iOS (the keyboard follows the finger once it reaches it), on drag on Android. On iOS 26 the bottom edge is UIKit's own scroll edge effect, like the top: the composer's container carries a `UIScrollEdgeElementContainerInteraction` for the message list (the local `scroll-edge-element` module; UIKit shapes the edge once when the interaction is added, so the container re-adds it whenever the composer's height or the keyboard changes), so UIKit softens messages under the composer the way it does under a system toolbar, and adapts it to Reduce Transparency itself. No fade is drawn there. A build without that module, older iOS and Android keep the drawn bottom fade over the composer's measured height (with the soft `EdgeMaterial` blur on iOS 26 with Reduce Transparency off). On Android the fade is a mask layer between the list and the floating chrome: nearly opaque (`edgeFade.cover`) under the composer and under the header, then the light `edgeFade` curve over `edgeFade.bottom` / `edgeFade.top` beyond them, painted in the chat's own background colour (`base` with an image background). With the keyboard open the composer's pill sits `space.x3` above it: the keyboard already covers the home indicator, so the chat frame takes the composer's safe-area padding back out of the keyboard offset instead of counting it twice. - Messages: - Activity rows show Bot DMs, posts elsewhere, memory commits, Bot creation, departures and stopped turns as one left-aligned meta line with a kind icon and tail ellipsis. They use the same `bubbleRow(true)` inset and single `space.x4` (12pt) top gap as a new message group, with no bottom padding or vertically centred minimum-height box: bubble → activity, activity → activity and activity → new message group keep the same boundary gap. Icons and avatars match the meta line height. In groups, the actor avatar occupies the existing speaker gutter, followed by the icon at the bubble's left edge and the description. In a Human DM, omit the current Bot's avatar and redundant name; Bot-DM actions show the other Bot's avatar after the icon. Missing avatars use the person icon. - Activity rows do not navigate on tap. Long-press (or the accessibility details action) opens the message sheet with the complete plain-text content in a scrollable, selectable block capped at `messageSheet.textHeightRatio` of the window height, then Copy/Share and an explicit Open conversation, Open memory or Open new Bot action when a destination exists; there are no reactions. Consecutive posts by the same Bot into the same Channel on the same day fold into a counted row whose menu opens the latest post. Memory opens the Bot's memory files, not a commit diff. Bot-to-Bot DMs remain read-only. Keep full actor/action/detail text in the accessibility label and menu even when the compact memory line uses only the icon and commit subject. @@ -211,7 +211,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - While the Bot works (thinking or working), a working status sits at the bottom of the message list, after the newest message, like the Web Client's. It has no background and no name: the Bot's avatar, then its current step with "…" in muted text. There is no Stop. Nothing sits fixed above the composer, so the composer never moves when a Bot starts or stops; scrolled up, the status is out of view like any new message. - The composer follows each platform's own compose bar: a round **+** button for attachments on the left, and beside it, a small gap apart, a field capsule with the placeholder "Message {Bot}" and a mic inside on the right that becomes Send once there is text. - iOS follows iOS 26 Messages: on iOS 26 with Reduce Transparency off, **+** is its own Liquid Glass circle and the field its own Liquid Glass capsule, both 40pt tall (`touch − space.x2`, like Grok's compose bar), and **+** keeps a 44pt hit area. Both use `glassEffect`, regular, tinted with the opaque `surface`, so they read as near-opaque white glass like Grok's compose bar and messages passing under the field never compete with the draft. They float over the messages with no bar behind them. Older iOS and Reduce Transparency keep the same layout on an opaque `surface` fill with a `borderStrong` hairline. Pressing **+** tints it like the header buttons. The trailing button is always a filled `fg` circle with a `base` glyph, like Grok's compose bar, not the Bot's accent: the mic at rest and while recording, Send once there is text. Android keeps the plain mic and the accent fill for Send. Every text field opens the keyboard in the app's chosen appearance, so a light app on a dark system gets the light keyboard. - - Android follows Material 3 (Google Messages): **+** is a standard icon button with no container and a borderless ripple, and the field is a filled capsule on the `raised` fill with no edge and no glass. The mic, Send and expand buttons ripple too. + - Android follows Material 3 (Google Messages): **+** is a filled round icon button on the same `raised` fill as the field (like Grok's compose bar), with a bounded ripple, and the field is a filled capsule on the `raised` fill with no edge and no glass. The mic, Send and expand buttons ripple too. - **+** sits outside the capsule, bottom-aligned as the field grows. While dictating it becomes **×** (cancel), and while the mic is held, a red dot. With one trailing button (the mic or Send) the capsule is one row: the field and the button. When the text no longer fits that one row, or with more (attached files, or the mic next to Send), the capsule becomes a rounded rectangle with the same corner radius and stacks like the web composer: the field on top at full width, and a row below with expand on the left and the buttons on the right. - **+** opens Photos, Camera and File below the composer. Picked files show as removable tiles inside the capsule, up to 10 per message. Send appears once there is text or a file. - While the field is focused and the clipboard holds an image, a panel above the composer, styled like the @ suggestions (Liquid Glass on iOS 26, otherwise the `sheet` layer), reads "Image on the clipboard" with a paste control. On iOS it is the system Paste button (`UIPasteControl`, through `expo-clipboard`'s `ClipboardPasteButton`, image only, icon only at accessibility text sizes), so no paste-permission prompt appears. On Android it is a text `Button` "Paste image" that reads the clipboard; Android only shows its own brief "pasted from your clipboard" notice. The image attaches as a JPEG tile and the row hides until something new is copied. The @ suggestions take that slot first. A text field cannot take an image paste on its own in React Native, so this is the paste path. diff --git a/apps/mobile/src/ui/chat/channel-title.tsx b/apps/mobile/src/ui/chat/channel-title.tsx index 6067e1fe30..81239249a9 100644 --- a/apps/mobile/src/ui/chat/channel-title.tsx +++ b/apps/mobile/src/ui/chat/channel-title.tsx @@ -5,6 +5,7 @@ import { useDesignTheme } from '../design/theme'; import { radius, space } from '../design/tokens'; import { Text } from '../primitives/text'; import { t } from '../strings'; +import { titleChipFill } from './header-chrome'; import { barLabelStyle } from './native-header'; import { GroupMark } from './group-mark'; @@ -38,7 +39,7 @@ export function ChannelTitle({ paddingLeft: (theme.touch - MARK) / 2, paddingRight: theme.touch / 2, borderRadius: radius.round, - backgroundColor: pressed ? theme.layer.raised.pressed : undefined, + ...titleChipFill(theme, pressed), })} > diff --git a/apps/mobile/src/ui/chat/chat-title.tsx b/apps/mobile/src/ui/chat/chat-title.tsx index ec7b54e97d..e3b5efebee 100644 --- a/apps/mobile/src/ui/chat/chat-title.tsx +++ b/apps/mobile/src/ui/chat/chat-title.tsx @@ -6,6 +6,7 @@ import { useDesignTheme } from '../design/theme'; import { radius, space } from '../design/tokens'; import { Text } from '../primitives/text'; import { t } from '../strings'; +import { titleChipFill } from './header-chrome'; import { barLabelStyle } from './native-header'; const AVATAR = 28; @@ -38,7 +39,7 @@ export function ChatTitle({ paddingLeft: bot === undefined ? theme.touch / 2 : (theme.touch - AVATAR) / 2, paddingRight: theme.touch / 2, borderRadius: radius.round, - backgroundColor: pressed ? theme.layer.raised.pressed : undefined, + ...titleChipFill(theme, pressed), })} > {bot === undefined ? null : } diff --git a/apps/mobile/src/ui/chat/composer-chrome.tsx b/apps/mobile/src/ui/chat/composer-chrome.tsx index 3d3150b0b2..e73ffd593c 100644 --- a/apps/mobile/src/ui/chat/composer-chrome.tsx +++ b/apps/mobile/src/ui/chat/composer-chrome.tsx @@ -36,13 +36,15 @@ export function ComposerCircleButton({ accessibilityLabel={label} testID={testID} onPress={onPress} - android_ripple={{ color: pressTint(theme.color.fg), borderless: true, radius: size / 2 }} + android_ripple={{ color: pressTint(theme.color.fg), foreground: true }} style={{ width: size, height: size, borderRadius: radius.round, + overflow: 'hidden', alignItems: 'center', justifyContent: 'center', + backgroundColor: theme.layer.raised.fill, }} > {children} diff --git a/apps/mobile/src/ui/chat/conversation-screen.tsx b/apps/mobile/src/ui/chat/conversation-screen.tsx index 84f259ad65..087b3e3900 100644 --- a/apps/mobile/src/ui/chat/conversation-screen.tsx +++ b/apps/mobile/src/ui/chat/conversation-screen.tsx @@ -73,9 +73,15 @@ import { ChannelTitle } from './channel-title'; import { ChatTitle } from './chat-title'; import { Composer, type ComposerInput } from './composer'; import { ClipboardImageOffer, useClipboardImage } from './clipboard-image'; -import { EdgeFade, HeaderBackButton } from './header-chrome'; +import { EdgeFade, HeaderBackButton, titleChipFill } from './header-chrome'; import { ScrollEdgeElement, nativeScrollEdge } from '../primitives/scroll-edge-element'; -import { barLabelStyle, glassHeader, useHeaderOverlap } from './native-header'; +import { + barLabelStyle, + floatingHeader, + glassHeader, + useHeaderOverlap, + useHeaderPadding, +} from './native-header'; import { useFollowLatest } from './follow-latest'; import { usePullToCompose } from './pull-to-compose'; import { PullToComposeHint } from './pull-to-compose-hint'; @@ -104,7 +110,7 @@ import { QuestionCard } from './question-card'; import { WorkingStatus } from './working-status'; import { TimeSeparator } from './time-separator'; import { AccentProvider, useDesignTheme } from '../design/theme'; -import { edgeFade, pullToCompose, space } from '../design/tokens'; +import { edgeFade, pullToCompose, radius, space } from '../design/tokens'; import { OpenFullClientButton } from '../open-full-client'; import { fullClientParams, type FullClientTarget } from '../../host/full-client'; import { leaveScreen } from '../shell/leave-screen'; @@ -292,6 +298,7 @@ function ChatConversation({ const theme = useDesignTheme(); const router = useRouter(); const headerOverlap = useHeaderOverlap(); + const headerPadding = useHeaderPadding(); const { width: windowWidth } = useWindowDimensions(); const listSafeArea = useListBottomSafeArea(); const { client, inbox, saved } = useHostSession(); @@ -347,7 +354,10 @@ function ChatConversation({ const [mentions, setMentions] = useState([]); const [caret, setCaret] = useState(0); const [selection, setSelection] = useState<{ start: number; end: number }>(); - const [allPreview, setAllPreview] = useState<{ key: string; preview: WireAllBotPreview }>(); + const [allPreview, setAllPreview] = useState<{ + key: string; + preview: WireAllBotPreview; + }>(); const [files, setFiles] = useState([]); const [attachError, setAttachError] = useState(false); const dictation = useDictation(setDraft); @@ -723,6 +733,8 @@ function ChatConversation({ minHeight: theme.touch, justifyContent: 'center', paddingHorizontal: theme.touch / 2, + borderRadius: radius.round, + ...titleChipFill(theme, false), }} > @@ -751,9 +763,11 @@ function ChatConversation({ ? { kind: 'channel', channelId } : undefined; const trailing = - fullClient === undefined ? null : ; + fullClient === undefined ? null : ( + + ); const ios = Platform.OS === 'ios'; - const back = ; + const back = ; const titleItem = ( {title} ); @@ -785,13 +799,16 @@ function ChatConversation({ ? t.dictationFailed : undefined; + const listTop = notice === undefined ? headerPadding : 0; + const fadeColor = background.kind === 'color' ? background.color : theme.color.base; + return ( <> title, headerRight: () => trailing, - ...(canGoBack ? {} : { headerLeft: () => back }), + ...(canGoBack && !floatingHeader ? {} : { headerLeft: () => back }), }), }} /> @@ -913,7 +930,7 @@ function ChatConversation({ {notice === undefined ? null : ( + {listTop > 0 ? ( + + ) : null} {nativeScrollEdge ? null : ( - + )} {composable && Platform.OS === 'ios' ? ( ({ + offset: start + stop.offset * (1 - start), + opacity: (stop.opacity / peak) * edgeFade.cover, + })), + ]; +} diff --git a/apps/mobile/src/ui/chat/header-chrome.tsx b/apps/mobile/src/ui/chat/header-chrome.tsx index 7c84e4c23b..667820f4c4 100644 --- a/apps/mobile/src/ui/chat/header-chrome.tsx +++ b/apps/mobile/src/ui/chat/header-chrome.tsx @@ -1,13 +1,14 @@ import { useId, type ReactNode } from 'react'; -import { Pressable, View } from 'react-native'; +import { Platform, Pressable, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'; import { frosted, pressTint, useDesignTheme, type DesignTheme } from '../design/theme'; -import { edge, edgeFade, radius, space } from '../design/tokens'; +import { edge, radius, space } from '../design/tokens'; import { DrawerMaterial, EdgeMaterial, useLiquidGlass } from '../primitives/drawer-material'; import { ChevronLeftIcon } from '../shell/icons'; import { t } from '../strings'; +import { edgeFadeStops } from './edge-fade'; const ICON = 22; @@ -71,6 +72,11 @@ export function headerGlassFill(theme: DesignTheme, glass: boolean, pressed: boo }; } +export function titleChipFill(theme: DesignTheme, pressed: boolean) { + if (Platform.OS === 'android') return headerGlassFill(theme, false, pressed); + return { backgroundColor: pressed ? theme.layer.raised.pressed : undefined }; +} + export function HeaderBackButton({ onPress, plain }: { onPress(): void; plain?: boolean }) { const theme = useDesignTheme(); return ( @@ -80,11 +86,22 @@ export function HeaderBackButton({ onPress, plain }: { onPress(): void; plain?: ); } -export function EdgeFade({ edge: side, height }: { edge: 'top' | 'bottom'; height: number }) { +export function EdgeFade({ + edge: side, + height, + cover = 0, + color, +}: { + edge: 'top' | 'bottom'; + height: number; + cover?: number; + color?: string; +}) { const theme = useDesignTheme(); const glass = useLiquidGlass(); const id = `fade-${useId().replace(/[^a-z0-9]/giu, '')}`; const top = side === 'top'; + const stops = edgeFadeStops(height, cover); return ( - - {edgeFade.stops.map((stop) => ( + + {(top + ? stops + : stops.map((stop) => ({ ...stop, offset: 1 - stop.offset })).reverse() + ).map((stop) => ( ))} diff --git a/apps/mobile/src/ui/chat/native-header.ts b/apps/mobile/src/ui/chat/native-header.ts index 046a6c2a02..1ae2dd9f72 100644 --- a/apps/mobile/src/ui/chat/native-header.ts +++ b/apps/mobile/src/ui/chat/native-header.ts @@ -9,5 +9,12 @@ export function useHeaderOverlap(): number { return Platform.OS === 'ios' ? height : 0; } +export const floatingHeader = Platform.OS === 'android'; + +export function useHeaderPadding(): number { + const height = useHeaderHeight(); + return floatingHeader ? height : 0; +} + export const barLabelStyle: TextStyle = Platform.OS === 'ios' ? { color: PlatformColor('label') } : {}; diff --git a/apps/mobile/src/ui/design/tokens.ts b/apps/mobile/src/ui/design/tokens.ts index c36ead5308..3bd5b5cef3 100644 --- a/apps/mobile/src/ui/design/tokens.ts +++ b/apps/mobile/src/ui/design/tokens.ts @@ -108,6 +108,7 @@ export const edgeFade = { { offset: 1, opacity: 0 }, ], blur: ['cc', '59', '14', '00'], + cover: 0.92, } as const; export const pointer = { diff --git a/apps/mobile/src/ui/open-full-client.tsx b/apps/mobile/src/ui/open-full-client.tsx index 57f6564c50..811462e67a 100644 --- a/apps/mobile/src/ui/open-full-client.tsx +++ b/apps/mobile/src/ui/open-full-client.tsx @@ -10,15 +10,17 @@ import { useDesignTheme } from './design/theme'; export function OpenFullClientButton({ target, bar = false, + plain = true, }: { target: FullClientTarget; bar?: boolean; + plain?: boolean; }) { const router = useRouter(); const open = () => router.push({ pathname: '/full-client', params: fullClientParams(target) }); if (bar) return ( - + ); diff --git a/apps/mobile/test/conversation-navigation.test.tsx b/apps/mobile/test/conversation-navigation.test.tsx index 102f8aa973..687f35597f 100644 --- a/apps/mobile/test/conversation-navigation.test.tsx +++ b/apps/mobile/test/conversation-navigation.test.tsx @@ -100,8 +100,11 @@ vi.mock('../src/ui/chat/native-header', () => ({ glassHeader: true, barLabelStyle: {}, useHeaderOverlap: () => 80, + floatingHeader: false, + useHeaderPadding: () => 0, })); vi.mock('../src/ui/chat/header-chrome', () => ({ + titleChipFill: () => ({}), HeaderBackButton: ({ onPress }: { onPress(): void }) => (