diff --git a/.agents/skills/ios-hig/SKILL.md b/.agents/skills/ios-hig/SKILL.md index c1d7c7cf01..52e711f781 100644 --- a/.agents/skills/ios-hig/SKILL.md +++ b/.agents/skills/ios-hig/SKILL.md @@ -43,7 +43,7 @@ Glass is the functional layer's material on iOS 26+. Its rules: - **Few custom surfaces.** System bars, sheets and menus get glass for free. Add `glassEffect` / `buttonStyle('glass')` by hand only to the few app-owned floating controls that need it (today: the tab island, the **+** button, drawer tabs and the drawer title capsule). Glass on glass muddies the hierarchy. - **Regular, not clear,** unless the surface floats over photos or video; clear glass over bright media may need a ~35% dark dimming layer. - **Colour sparingly.** Labels and symbols on glass stay monochrome; one prominent (tinted) action per view, coloured by its background, not its label. Keep the accent off most controls; selected tab, unread badge and the primary action are where it belongs. Check legibility at the resting scroll position. -- **Separate controls from content with the scroll edge effect,** not a painted bar background. Leave it on its automatic style; use it only where content scrolls under floating controls; one per view. Our deliberate exception: the chat screen hides the native header and turns scroll edge effects off, using `edgeFade` instead (see `DESIGN.md`, Chat). Do not "fix" that without a Human decision. +- **Separate controls from content with the scroll edge effect,** not a painted bar background. Leave it on its automatic style; use it only where content scrolls under floating controls; one per view. The chat screen uses the native transparent header with the automatic top edge effect over a top-to-bottom (not inverted) list; an inverted list under a transparent header dims on iOS 26 (see `DESIGN.md`, Chat). - **Fallbacks ship with the glass:** iOS < 26 → bordered/opaque `raised` surface; **Reduce Transparency** → opaque (follow `DrawerMaterial` in `src/ui/primitives/drawer-material.ios.tsx`); **Increase Contrast** → stronger edge; **Reduce Motion** → no morph; accessibility text sizes → an opaque rounded rectangle when text wraps inside a capsule. - **Hit targets** stay ≥ 44 × 44 pt however small the glass shape looks. @@ -67,7 +67,7 @@ Lessons from a production Expo 58 app with iOS 26 glass (t3code; notes in `/mnt/ - Tab bar: navigation only, always visible on top-level screens, labelled, never disabled. - Glass only on the functional layer; every glass surface has the iOS < 26, Reduce Transparency, Increase Contrast and large-text fallbacks. - At most one tinted primary action per view; labels on glass monochrome. -- Scroll edge effect automatic, only under floating controls, one per view (chat's documented exception aside). +- Scroll edge effect automatic, only under floating controls, one per view. - Drift from `DESIGN.md` named in the PR, not silently resolved. ## Keeping current diff --git a/.agents/skills/ios-hig/references/components.md b/.agents/skills/ios-hig/references/components.md index b0848cef71..63bd4479f3 100644 --- a/.agents/skills/ios-hig/references/components.md +++ b/.agents/skills/ios-hig/references/components.md @@ -82,7 +82,7 @@ Build: `SheetScreen` + `sheetScreenOptions` (`src/ui/primitives/sheet-screen.tsx Build: native stack headers and `NativeTabs` apply it automatically; React Native Screens exposes per-edge `scrollEdgeEffects` for a screen. Do not paint a solid or translucent background under a bar instead of using it. -Today: chat turns all edges `hidden` and fades with `edgeFade` because a transparent native header dims the inverted list (`DESIGN.md` → Chat header). Other screens keep the system default. +Today: chat keeps the automatic top edge effect under a transparent native header; its list runs top to bottom because an inverted list under that header dims on iOS 26 (`DESIGN.md` → Chat header). Other screens keep the system default. ## Tab bars diff --git a/.github/pr-screenshots/2344-native-chat/1-2-dm-open-dark.png b/.github/pr-screenshots/2344-native-chat/1-2-dm-open-dark.png new file mode 100644 index 0000000000..b950abc3e3 Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/1-2-dm-open-dark.png differ diff --git a/.github/pr-screenshots/2344-native-chat/1-2-dm-open-light.png b/.github/pr-screenshots/2344-native-chat/1-2-dm-open-light.png new file mode 100644 index 0000000000..43c8f75f31 Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/1-2-dm-open-light.png differ diff --git a/.github/pr-screenshots/2344-native-chat/1-dm-open-dark.png b/.github/pr-screenshots/2344-native-chat/1-dm-open-dark.png new file mode 100644 index 0000000000..b40d7b469a Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/1-dm-open-dark.png differ diff --git a/.github/pr-screenshots/2344-native-chat/1-dm-open-light.png b/.github/pr-screenshots/2344-native-chat/1-dm-open-light.png new file mode 100644 index 0000000000..937934cc2c Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/1-dm-open-light.png differ diff --git a/.github/pr-screenshots/2344-native-chat/3-mid-scroll-dark.png b/.github/pr-screenshots/2344-native-chat/3-mid-scroll-dark.png new file mode 100644 index 0000000000..8033382556 Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/3-mid-scroll-dark.png differ diff --git a/.github/pr-screenshots/2344-native-chat/3-mid-scroll-image-dark.png b/.github/pr-screenshots/2344-native-chat/3-mid-scroll-image-dark.png new file mode 100644 index 0000000000..d54959e5c4 Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/3-mid-scroll-image-dark.png differ diff --git a/.github/pr-screenshots/2344-native-chat/3-mid-scroll-image-light.png b/.github/pr-screenshots/2344-native-chat/3-mid-scroll-image-light.png new file mode 100644 index 0000000000..7be7fceef8 Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/3-mid-scroll-image-light.png differ diff --git a/.github/pr-screenshots/2344-native-chat/3-mid-scroll-light.png b/.github/pr-screenshots/2344-native-chat/3-mid-scroll-light.png new file mode 100644 index 0000000000..f724089e10 Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/3-mid-scroll-light.png differ diff --git a/.github/pr-screenshots/2344-native-chat/3-mid-scroll-theme-dark.png b/.github/pr-screenshots/2344-native-chat/3-mid-scroll-theme-dark.png new file mode 100644 index 0000000000..dce40104ac Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/3-mid-scroll-theme-dark.png differ diff --git a/.github/pr-screenshots/2344-native-chat/3-mid-scroll-theme-light.png b/.github/pr-screenshots/2344-native-chat/3-mid-scroll-theme-light.png new file mode 100644 index 0000000000..6b0e9dcec1 Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/3-mid-scroll-theme-light.png differ diff --git a/.github/pr-screenshots/2344-native-chat/3e5f-dm-header-light.png b/.github/pr-screenshots/2344-native-chat/3e5f-dm-header-light.png new file mode 100644 index 0000000000..a63e5c5976 Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/3e5f-dm-header-light.png differ diff --git a/.github/pr-screenshots/2344-native-chat/3e5f-group-reduce-transparency-dark.png b/.github/pr-screenshots/2344-native-chat/3e5f-group-reduce-transparency-dark.png new file mode 100644 index 0000000000..6ce8fa5dac Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/3e5f-group-reduce-transparency-dark.png differ diff --git a/.github/pr-screenshots/2344-native-chat/4-keyboard-open-dark.png b/.github/pr-screenshots/2344-native-chat/4-keyboard-open-dark.png new file mode 100644 index 0000000000..2ad185496d Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/4-keyboard-open-dark.png differ diff --git a/.github/pr-screenshots/2344-native-chat/4-keyboard-open-light.png b/.github/pr-screenshots/2344-native-chat/4-keyboard-open-light.png new file mode 100644 index 0000000000..7f3c951ecc Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/4-keyboard-open-light.png differ diff --git a/.github/pr-screenshots/2344-native-chat/5-after-hard-drag-light.png b/.github/pr-screenshots/2344-native-chat/5-after-hard-drag-light.png new file mode 100644 index 0000000000..43c8f75f31 Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/5-after-hard-drag-light.png differ diff --git a/.github/pr-screenshots/2344-native-chat/6-status-bar-top-dark.png b/.github/pr-screenshots/2344-native-chat/6-status-bar-top-dark.png new file mode 100644 index 0000000000..5e4127890e Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/6-status-bar-top-dark.png differ diff --git a/.github/pr-screenshots/2344-native-chat/6-status-bar-top-light.png b/.github/pr-screenshots/2344-native-chat/6-status-bar-top-light.png new file mode 100644 index 0000000000..897867bb69 Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/6-status-bar-top-light.png differ diff --git a/.github/pr-screenshots/2344-native-chat/6b-after-drag-light.png b/.github/pr-screenshots/2344-native-chat/6b-after-drag-light.png new file mode 100644 index 0000000000..119b758b37 Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/6b-after-drag-light.png differ diff --git a/.github/pr-screenshots/2344-native-chat/6b-after-slow-drag-light.png b/.github/pr-screenshots/2344-native-chat/6b-after-slow-drag-light.png new file mode 100644 index 0000000000..8a191fa545 Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/6b-after-slow-drag-light.png differ diff --git a/.github/pr-screenshots/2344-native-chat/7-group-header-dark.png b/.github/pr-screenshots/2344-native-chat/7-group-header-dark.png new file mode 100644 index 0000000000..4395f4abd0 Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/7-group-header-dark.png differ diff --git a/.github/pr-screenshots/2344-native-chat/7-group-header-light.png b/.github/pr-screenshots/2344-native-chat/7-group-header-light.png new file mode 100644 index 0000000000..6b28dae889 Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/7-group-header-light.png differ diff --git a/.github/pr-screenshots/2344-native-chat/ec1f-1-group-ax5-dark.png b/.github/pr-screenshots/2344-native-chat/ec1f-1-group-ax5-dark.png new file mode 100644 index 0000000000..7957fa0d7b Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/ec1f-1-group-ax5-dark.png differ diff --git a/.github/pr-screenshots/2344-native-chat/ec1f-1-group-ax5-light.png b/.github/pr-screenshots/2344-native-chat/ec1f-1-group-ax5-light.png new file mode 100644 index 0000000000..be344b6e3c Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/ec1f-1-group-ax5-light.png differ diff --git a/.github/pr-screenshots/2344-native-chat/ec1f-2-group-reduce-transparency-dark.png b/.github/pr-screenshots/2344-native-chat/ec1f-2-group-reduce-transparency-dark.png new file mode 100644 index 0000000000..834b3d161c Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/ec1f-2-group-reduce-transparency-dark.png differ diff --git a/.github/pr-screenshots/2344-native-chat/ec1f-2-group-reduce-transparency-light.png b/.github/pr-screenshots/2344-native-chat/ec1f-2-group-reduce-transparency-light.png new file mode 100644 index 0000000000..db03c7c126 Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/ec1f-2-group-reduce-transparency-light.png differ diff --git a/.github/pr-screenshots/2344-native-chat/ec1f-3-dm-rest-light.png b/.github/pr-screenshots/2344-native-chat/ec1f-3-dm-rest-light.png new file mode 100644 index 0000000000..1ab38ca74f Binary files /dev/null and b/.github/pr-screenshots/2344-native-chat/ec1f-3-dm-rest-light.png differ diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index 39545846db..968d695441 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -157,17 +157,17 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w **Chat** -- Header: our own, floating over the messages with no bar, like Telegram. Back on the left, the Bot's computer on the right, and a centered chip that opens the Bot profile. The native stack header is hidden (`headerShown: false`): with a transparent native header on iOS 26, UIKit puts a dimming overlay over the whole inverted message list, and turning the scroll edge effects off on the screen does not remove it. The swipe back still works. - - The chip is a full-round pill on a frosted `raised` (the fill at `glass.tint` opacity) with a hairline `edge`, so messages show faintly through it as they pass under. In one row it holds the Bot's avatar (no state ring), its name, and a state dot: `success` when idle, `accent` while thinking or working, `warning` when it waits for the Human, `danger` when blocked, `muted` when paused. The state is read out in the chip's accessibility label. - - The chip's padding is concentric with its corners: it is `theme.touch` tall, so its corner radius is half that, and the avatar and the dot each sit that same distance from their end of the pill (padding = corner radius − the item's own radius). - - Back and the computer button are round buttons of `theme.touch`, on the same frosted `raised` with a hairline `edge`, on every platform. Both icons are `fg`, never the Bot's accent. - - Messages run under the header and the status bar and fade there: a light static gradient from `base` to transparent (`edgeFade.stops`, at most 80%) covers the header and `edgeFade.top` below it, on every platform. At rest the list keeps the header's height free above the oldest message. UIKit's scroll edge effects stay off (`scrollEdgeEffects` all `hidden`), because the header is our own. On iOS 26 with Reduce Transparency off, both fades also carry a soft scroll edge like HIG Scroll views: an `ultraThin` system material (`EdgeMaterial`, its hosting view set to `ignoreSafeArea="all"` so it reaches the screen edge) strongest at the screen edge and easing out quickly (`edgeFade.blur`, at most 80%), so it is nearly clear by the bottom of the header (top) or the top of the composer (bottom). Like Grok's chat, text under the status bar is softened, not hidden; messages stay readable right up to the edge. On iOS 26 the colour fade is not drawn, because a `base` tint does not match a custom chat background. The bottom fade spans the composer's measured height plus `edgeFade.bottom`. The blur is composited by the system, so the list does not re-render. The header's back button, title capsule and trailing button sit on Liquid Glass there instead of the frosted `raised` fill. Older iOS, Reduce Transparency and Android keep the colour fade and frosted fill only. - - The list runs to the bottom of the screen, and the composer floats over it with no bar of its own; its pill keeps an opaque `surface` fill, so nothing shows through where the Human types; messages pass around and under it. 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. +- 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. + - 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; its pill keeps an opaque `surface` fill, so nothing shows through where the Human types; messages pass around and under it. 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 with Reduce Transparency off, the bottom fade also carries the soft `EdgeMaterial` blur over the composer's measured height. 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. - - An inverted list. Messages from the same author within 5 minutes form a group, and a time separator appears after a 30-minute gap. - - At the newest message the list follows a streaming reply. Once the Human begins a drag or scrolls up into history, nothing moves the list under them: the visual playback cursor holds while received text continues to queue and persist. Only finishing a return gesture near the bottom or choosing the jump button resumes playback; a layout-driven scroll callback cannot rearm following. A round jump button (`raised`, with a ⌄) then floats centered above the composer, with an accent badge counting the Bot messages that arrived since they left (99+ at most), like the Web Client's "Return to latest messages". Tapping it, sending a message, or scrolling back down to the newest message follows again and hides the button. + - A top-to-bottom list (not inverted), oldest first, on one code path for iOS and Android. It opens hidden and is shown once it has been scrolled to the end and its content has settled for a frame, so it never flashes the oldest messages first. Short conversations sit at the bottom, above the composer. Older history loads when the Human nears the top (`onStartReached`), and `maintainVisibleContentPosition` keeps the messages on screen where they were while it is prepended. Messages from the same author within 5 minutes form a group, and a time separator appears after a 30-minute gap. + - At the newest message the list follows a streaming reply: content growth pins it to the end with a non-animated scroll, at most once per frame. Once the Human begins a drag or scrolls up into history, nothing moves the list under them: the visual playback cursor holds while received text continues to queue and persist. Only finishing a return gesture near the bottom or choosing the jump button resumes playback; a layout-driven scroll callback can neither rearm nor release following. Only a drag, the status-bar scroll to top or a jump to a linked message leaves the newest message, so the inset adjustment, content-size reports and `maintainVisibleContentPosition` corrections while the list first fills cannot strand it at the oldest message. A round jump button (`raised`, with a ⌄) then floats centered above the composer, with an accent badge counting the Bot messages that arrived since they left (99+ at most), like the Web Client's "Return to latest messages". Tapping it, sending a message, or scrolling back down to the newest message follows again and hides the button. - Settings → **Message display** offers **Streaming** and **Complete bubbles**, saved on this device. Streaming uses a local playback queue with a short prebuffer and reserve, releasing graphemes on a bounded clock. Rate changes follow queue depth gradually; clauses, sentences and paragraphs add progressively longer pauses. Android fades the active prose tail on the UI thread (180ms fade, up to 72ms stagger, at most 48 animated spans, with up to eight new spans per update); earlier text stays visible. Source commitment preserves the displayed cursor and row while the rest drains, then enables message actions. Historical messages open directly. Reduce Motion skips the fade. Code/table/native-only Markdown keep their established renderer; their content still follows the playback cursor. Complete bubbles keeps the working indicator and reveals each committed Channel Message independently. Switching modes changes presentation only; received drafts, committed messages and interruption semantics remain owned by Conversation and the Host. A draft-only append updates its row subscription while the timeline snapshot and historical message references stay stable. The bottom fade belongs to the keyboard-sticky composer. - Android Markdown uses synchronous native text/layout for prose, headings, lists, quotes, code and tables. Completed source blocks retain their keys and avoid rerendering when only the tail changes. Images and native-only extensions retain the enriched renderer. Drafts and committed messages keep the same renderer to avoid a settlement-only layout switch; iOS keeps its existing enriched implementation. - Append-only Android Markdown reparses the changing suffix with boundary lookback, retaining completed block identities. Blocks retain the lexer result with its exact source; rendering and window-support checks reuse it only while that source matches. A repaired streaming tail or a source-only block uses a fresh parse. Corrections and document-wide references rebuild the document. Prose reveal segmentation likewise retains a grapheme boundary and revisits the changing suffix, including a joined emoji or combining mark; it must not repeatedly segment the complete paragraph. @@ -219,7 +219,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w 3. **Auto approval template**: Off, Cautious and Get the work done (plus any other template on the Host), each with its one-line hint, the current one checked. - The mic dictates with the phone's speech recognizer, and the words fill the field so the Human edits them before sending. Like Telegram's voice button: hold the mic to talk and release to stop; while holding, a lock appears above the mic: slide left to cancel or up to lock. A tap works like a lock: it keeps listening until **■** stops it or the **×** that replaces **+** cancels it and clears the text. Dictation stays in the same row, and its status shows as the field's placeholder. A refused microphone or speech permission shows a notice and leaves the mic in place. - A group Channel opens the same chat screen, with these differences: - - The header is the same floating header. Its centered chip, on the same frosted `raised` with a hairline `edge` like the Bot chip, holds the group's photo (or a round `#` mark), the Channel name and its member count; it opens **Group info**. The full client button sits on the right. + - The header is the same native header. Its title holds the group's photo (or a round `#` mark), the Channel name and its member count; it opens **Group info**. The full client button is the trailing item. - Like Slack and the Web Client, each Bot message sits in a row with a 32 pt avatar column on the left: a rounded square (`radius.sm`, no state ring). The first bubble of each Bot's group, and each Bot's streaming draft, carry the Bot's name above it in `meta`. The avatar rests beside the group's last bubble, its bottom edge level with that bubble's bottom edge, never with its reaction or delivery row. That last bubble's bottom-left corner uses the avatar's `radius.sm`, so the two bottom edges meet with matching corners. While the group scrolls past, the avatar sticks to the list's visible bottom edge (above the floating composer and its fade) and follows the group up until it reaches the group's first bubble. This runs on the UI thread from the list's scroll offset (Reanimated `useScrollOffset` and `measure`). A Bot missing from the roster gets a `surface` rounded square with its initial. A different Bot starts a new group even within 5 minutes. - `accent` stays the app's accent, since no one Bot owns the chat. - Working member Bots share one row: up to three avatars stacked (each ringed in `base`), then "Working…"; one Bot shows its own step. @@ -306,7 +306,7 @@ Each layer has `fill`, `pressed`, `selected` (neutral, such as the current tab), - Markdown bodies are the exception: `MarkdownView` renders them as native text with `react-native-enriched-markdown`, styled from the same tokens in `src/ui/markdown-style.ts`. 3. **A new visual element is added to `primitives/`, `chat/` or `shell/` first.** The PR says why the existing ones do not fit. 4. **Interactive elements meet the touch target and carry an `accessibilityLabel`.** The touch target is `theme.touch`: 44pt on iOS, 48dp on Android. -5. **Lists virtualize.** The chat is an inverted list. +5. **Lists virtualize.** The chat is a top-to-bottom `FlatList` that starts at its end. 6. **Motion and haptics are limited.** - Motion uses Reanimated, only for bubble entry, a held bubble's lift, the message menu, card collapse, the status dot, the Host drawer, the image viewer's gestures and the composer growing line by line (up to five lines, then it scrolls). - Haptics fire only on send, approve or deny, submitting a choice, and opening a message's menu. diff --git a/apps/mobile/src/ui/chat/bot-tone.ts b/apps/mobile/src/ui/chat/bot-tone.ts index 028264cec0..5352532b29 100644 --- a/apps/mobile/src/ui/chat/bot-tone.ts +++ b/apps/mobile/src/ui/chat/bot-tone.ts @@ -12,8 +12,3 @@ export function botStateTone(bot: RosterBot): ColorRole { export function botBusy(bot: RosterBot): boolean { return !bot.paused && (bot.state === 'thinking' || bot.state === 'working'); } - -export function botDotTone(bot: RosterBot): ColorRole { - if (!bot.paused && bot.state === 'idle') return 'success'; - return botStateTone(bot); -} diff --git a/apps/mobile/src/ui/chat/channel-title.tsx b/apps/mobile/src/ui/chat/channel-title.tsx index 4231694c66..6067e1fe30 100644 --- a/apps/mobile/src/ui/chat/channel-title.tsx +++ b/apps/mobile/src/ui/chat/channel-title.tsx @@ -1,11 +1,11 @@ -import { SourceName } from './source-name'; import { Pressable } from 'react-native'; import type { RosterChannel } from '../../host/client'; -import { frosted, useDesignTheme } from '../design/theme'; -import { edge, radius, space } from '../design/tokens'; +import { useDesignTheme } from '../design/theme'; +import { radius, space } from '../design/tokens'; import { Text } from '../primitives/text'; import { t } from '../strings'; +import { barLabelStyle } from './native-header'; import { GroupMark } from './group-mark'; const MARK = 28; @@ -30,7 +30,7 @@ export function ChannelTitle({ disabled={channel === undefined} onPress={onPress} style={({ pressed }) => ({ - maxWidth: '100%', + flexShrink: 1, flexDirection: 'row', alignItems: 'center', gap: space.x3, @@ -38,18 +38,13 @@ export function ChannelTitle({ paddingLeft: (theme.touch - MARK) / 2, paddingRight: theme.touch / 2, borderRadius: radius.round, - borderWidth: edge.hairline, - borderColor: theme.layer.raised.edge, - backgroundColor: pressed ? theme.layer.raised.pressed : frosted(theme.layer.raised.fill), + backgroundColor: pressed ? theme.layer.raised.pressed : undefined, })} > - - {members === undefined ? null : ( - - {members} - - )} + + {name} + ); } diff --git a/apps/mobile/src/ui/chat/chat-title.tsx b/apps/mobile/src/ui/chat/chat-title.tsx index 3f2d100de6..ec7b54e97d 100644 --- a/apps/mobile/src/ui/chat/chat-title.tsx +++ b/apps/mobile/src/ui/chat/chat-title.tsx @@ -1,17 +1,14 @@ -import { SourceName } from './source-name'; -import { Pressable, View } from 'react-native'; +import { Pressable } from 'react-native'; import type { RosterBot } from '../../host/client'; import { BotAvatar } from '../bot-avatar'; import { useDesignTheme } from '../design/theme'; import { radius, space } from '../design/tokens'; -import { DrawerMaterial, useLiquidGlass } from '../primitives/drawer-material'; +import { Text } from '../primitives/text'; import { t } from '../strings'; -import { botDotTone } from './bot-tone'; -import { headerGlassFill } from './header-chrome'; +import { barLabelStyle } from './native-header'; const AVATAR = 28; -const DOT = space.x3; export function ChatTitle({ bot, @@ -23,7 +20,6 @@ export function ChatTitle({ onPress(): void; }) { const theme = useDesignTheme(); - const glass = useLiquidGlass(); const status = bot === undefined ? undefined : bot.paused ? t.paused : t.state[bot.state]; const name = bot?.displayName ?? fallback; return ( @@ -34,31 +30,21 @@ export function ChatTitle({ disabled={bot === undefined} onPress={onPress} style={({ pressed }) => ({ - maxWidth: '100%', + flexShrink: 1, flexDirection: 'row', alignItems: 'center', gap: space.x3, minHeight: Math.max(theme.touch, theme.type.body.line * theme.fontScale + space.x3), paddingLeft: bot === undefined ? theme.touch / 2 : (theme.touch - AVATAR) / 2, - paddingRight: bot === undefined ? theme.touch / 2 : (theme.touch - DOT) / 2, + paddingRight: theme.touch / 2, borderRadius: radius.round, - ...headerGlassFill(theme, glass, pressed), + backgroundColor: pressed ? theme.layer.raised.pressed : undefined, })} > - {glass ? : null} {bot === undefined ? null : } - - {bot === undefined ? null : ( - - )} + + {name} + ); } diff --git a/apps/mobile/src/ui/chat/conversation-screen.tsx b/apps/mobile/src/ui/chat/conversation-screen.tsx index 33cbac1fbf..a675524a64 100644 --- a/apps/mobile/src/ui/chat/conversation-screen.tsx +++ b/apps/mobile/src/ui/chat/conversation-screen.tsx @@ -7,6 +7,7 @@ import { FlatList, Platform, Pressable, + useWindowDimensions, View, type ViewInstance, } from 'react-native'; @@ -59,7 +60,8 @@ import { botBusy } from './bot-tone'; import { ChannelTitle } from './channel-title'; import { ChatTitle } from './chat-title'; import { Composer } from './composer'; -import { ChatHeader, EdgeFade, HeaderBackButton, useChatHeaderHeight } from './header-chrome'; +import { EdgeFade, HeaderBackButton } from './header-chrome'; +import { barLabelStyle, glassHeader, useHeaderOverlap } from './native-header'; import { useFollowLatest } from './follow-latest'; import { useMarkRead } from './mark-read'; import { useBotNames } from './use-bot-names'; @@ -88,10 +90,11 @@ import { TimeSeparator } from './time-separator'; import { AccentProvider, useDesignTheme } from '../design/theme'; import { edgeFade, space } from '../design/tokens'; import { OpenFullClientButton } from '../open-full-client'; +import { fullClientParams, type FullClientTarget } from '../../host/full-client'; import { leaveScreen } from '../shell/leave-screen'; import { Text } from '../primitives/text'; import { MarkdownViewportContext } from '../markdown-viewport'; -import { KeyboardChatFrame } from '../primitives/keyboard-frame'; +import { KeyboardChatFrame, useListBottomSafeArea } from '../primitives/keyboard-frame'; import { t } from '../strings'; type Row = ChatRow | { kind: 'draft'; key: string; draft: ConversationDraftReference }; @@ -232,7 +235,9 @@ function ChatConversation({ }) { const theme = useDesignTheme(); const router = useRouter(); - const headerHeight = useChatHeaderHeight(); + const headerOverlap = useHeaderOverlap(); + const { width: windowWidth } = useWindowDimensions(); + const listSafeArea = useListBottomSafeArea(); const { client, inbox, saved } = useHostSession(); const host = useHostSnapshot(client, focused); const dm = place.kind === 'dm'; @@ -295,6 +300,7 @@ function ChatConversation({ const [actionsFor, setActionsFor] = useState(); const [reactError, setReactError] = useState(false); const [composerHeight, setComposerHeight] = useState(0); + const [viewportHeight, setViewportHeight] = useState(0); const list = useAnimatedRef>(); const jumped = useRef(undefined); const [answered, setAnswered] = useState>(new Set()); @@ -325,7 +331,6 @@ function ChatConversation({ ), [visible, resolutions, dm, playback], ); - const history = useMemo(() => [...chronological].reverse(), [chronological]); const [speakers] = useState(createSpeakerRegistry); const groups = useMemo(() => messageGroups(chronological), [chronological]); const groupOf = useMemo(() => new Map(groups.map((entry) => [entry.key, entry.group])), [groups]); @@ -340,13 +345,13 @@ function ChatConversation({ }, [speakers, groups, shownDrafts]); const scroll = useScrollOffset(list); const viewport = useAnimatedRef(); - const invertedContent = useAnimatedRef(); + const content = useAnimatedRef(); const attachList = useCallback( (node: FlatList | null) => { list(node); - invertedContent(node?.getNativeScrollRef()?.getInnerViewRef() ?? null); + content(node?.getNativeScrollRef()?.getInnerViewRef() ?? null); }, - [list, invertedContent], + [list, content], ); const viewportLayout = useSharedValue(0); const stage = useMemo( @@ -359,10 +364,10 @@ function ChatConversation({ }), [speakers, scroll, viewport, viewportLayout, composerHeight], ); - const latest = useFollowLatest(list, visible, playback?.setFollowing); + const latest = useFollowLatest(list, visible, playback?.setFollowing, headerOverlap); const markdownViewport = useMemo( - () => ({ scroll, viewport, invertedContent, layout: viewportLayout, active: focused }), - [scroll, viewport, invertedContent, viewportLayout, focused], + () => ({ scroll, viewport, content, layout: viewportLayout, active: focused }), + [scroll, viewport, content, viewportLayout, focused], ); const readMount = useMarkRead(client, inbox, conversation, activeChannel, !latest.away); const mountRef = dictation.mountRef; @@ -380,14 +385,14 @@ function ChatConversation({ const rows = useMemo( () => [ + ...chronological, ...shownDrafts.map((entry): Row => ({ kind: 'draft', key: `draft:${entry.draftId}`, draft: entry, })), - ...history, ], - [shownDrafts, history], + [shownDrafts, chronological], ); const jump = () => { @@ -395,6 +400,7 @@ function ChatConversation({ const index = rows.findIndex((row) => row.kind === 'message' && row.message.id === focus); if (index >= 0) { jumped.current = focus; + latest.hold(); list.current?.scrollToIndex({ index, viewPosition: 0.5, animated: true }); } else if (latestWindowOnly && snapshot.status === 'live') jumped.current = focus; else if (snapshot.status === 'live' && snapshot.hasOlder && !snapshot.loadingOlder) @@ -625,6 +631,53 @@ function ChatConversation({ const missing = host.roster !== undefined && (dm ? bot === undefined : channel === undefined && !botDm); const leave = () => leaveScreen(router); + const canGoBack = router.canGoBack(); + const title = dm ? ( + router.push(`/bot/${bot?.slug ?? slug}`)} + /> + ) : botDm ? ( + + + {channelDetails.members + .map( + (member) => + roster?.bots.find((entry) => entry.slug === member)?.displayName ?? + t.activityRow.unavailableBot, + ) + .join('、')} + + + ) : ( + + router.push({ pathname: '/group/[id]', params: { id: channel?.id ?? channelId } }) + } + /> + ); + const fullClient: FullClientTarget | undefined = + slug !== undefined + ? { kind: 'dm', slug } + : channelId !== undefined + ? { kind: 'channel', channelId } + : undefined; + const trailing = + fullClient === undefined ? null : ; + const ios = Platform.OS === 'ios'; + const back = ; + const titleItem = ( + {title} + ); const notice = hostRefused ? t.refused : snapshot.status === 'refused' @@ -657,14 +710,63 @@ function ChatConversation({ <> [ + ...(canGoBack + ? [] + : [ + { + type: 'button' as const, + label: t.back, + icon: { type: 'sfSymbol' as const, name: 'chevron.left' as const }, + onPress: leave, + }, + ]), + { type: 'spacing' as const, spacing: 0 }, + { type: 'custom' as const, element: titleItem }, + ], + unstable_headerRightItems: () => + fullClient === undefined + ? [] + : [ + { + type: 'button' as const, + label: t.openFullClient, + icon: { type: 'sfSymbol' as const, name: 'macwindow' as const }, + onPress: () => + router.push({ + pathname: '/full-client', + params: fullClientParams(fullClient), + }), + }, + ], + } + : { + headerTitleAlign: 'left' as const, + headerTitle: () => title, + headerRight: () => trailing, + ...(canGoBack ? {} : { headerLeft: () => back }), + }), }} /> } + backgroundColor={background.kind === 'color' ? background.color : theme.color.base} + background={ + background.kind === 'image' ? ( + + ) : undefined + } onComposerLayout={(event) => setComposerHeight(event.nativeEvent.layout.height)} composer={ @@ -715,7 +817,7 @@ function ChatConversation({ {notice === undefined ? null : ( { + onLayout={(event) => { viewportLayout.value += 1; + setViewportHeight(event.nativeEvent.layout.height); }} > row.key} renderItem={renderItem} - onContentSizeChange={jump} onScrollToIndexFailed={(info) => { list.current?.scrollToOffset({ offset: info.averageItemLength * info.index, @@ -793,14 +903,17 @@ function ChatConversation({ 100, ); }} - onEndReached={() => void conversation?.loadOlder()} - onEndReachedThreshold={0.5} + {...(latest.ready ? { onStartReached: () => void conversation?.loadOlder() } : {})} + onStartReachedThreshold={0.5} {...latest.listProps} + onContentSizeChange={(width: number, height: number) => { + latest.listProps.onContentSizeChange(width, height); + jump(); + }} scrollEventThrottle={32} - keyboardDismissMode="interactive" + keyboardDismissMode={ios ? 'interactive' : 'on-drag'} keyboardShouldPersistTaps="handled" - ListHeaderComponent={} - ListFooterComponent={ + ListHeaderComponent={ snapshot.loadingOlder ? ( ) : undefined } + ListFooterComponent={} ListEmptyComponent={ snapshot.status === 'opening' ? ( @@ -851,44 +965,6 @@ function ChatConversation({ ) : null} - - } - center={ - dm ? ( - router.push(`/bot/${bot?.slug ?? slug}`)} - /> - ) : botDm ? ( - - {channelDetails.members - .map( - (member) => - roster?.bots.find((entry) => entry.slug === member)?.displayName ?? - t.activityRow.unavailableBot, - ) - .join('、')} - - ) : ( - - router.push({ pathname: '/group/[id]', params: { id: channel?.id ?? channelId } }) - } - /> - ) - } - right={ - slug !== undefined ? ( - - ) : channelId !== undefined ? ( - - ) : null - } - /> setViewing(undefined)} onShare={share} /> , start: number): number { + return Math.max(0 - start, metrics.content - metrics.layout); } export function unseenSince( @@ -24,22 +40,59 @@ export function unseenSince( type ScrollEvent = NativeSyntheticEvent; +const KEEP_VISIBLE = { minIndexForVisible: 0 }; + export function useFollowLatest( list: RefObject | null>, messages: readonly ConversationMessage[], onFollowingChange?: (following: boolean) => void, + start = 0, ) { const [following, setFollowing] = useState(true); const [away, setAway] = useState(false); const [anchor, setAnchor] = useState(); + const [ready, setReady] = useState(false); const followingNow = useRef(true); const dragging = useRef(false); - const jumping = useRef(false); const latestId = useRef(undefined); latestId.current = messages.at(-1)?.id; + const hasRows = useRef(false); + hasRows.current = messages.length > 0; + const startInset = useRef(start); + startInset.current = start; + const metrics = useRef({ content: 0, layout: 0, offset: 0 }); + const frame = useRef(undefined); + const sizes = useRef(0); + const settled = useRef(false); + const attempts = useRef(0); useLayoutEffect(() => { if (!followingNow.current) onFollowingChange?.(false); }, [onFollowingChange]); + useLayoutEffect( + () => () => { + if (frame.current !== undefined) cancelAnimationFrame(frame.current); + frame.current = undefined; + }, + [], + ); + + const measureEvent = useCallback((event: ScrollEvent): number => { + const native = event.nativeEvent; + metrics.current = { + content: native.contentSize?.height ?? metrics.current.content, + layout: native.layoutMeasurement?.height ?? metrics.current.layout, + offset: native.contentOffset.y, + }; + return distanceFromLatest(metrics.current); + }, []); + + const scrollToLatest = useCallback( + (animated: boolean) => { + const offset = latestOffset(metrics.current, startInset.current); + list.current?.scrollToOffset({ offset, animated }); + }, + [list], + ); const hold = useCallback(() => { if (!followingNow.current) return; @@ -51,77 +104,109 @@ export function useFollowLatest( }, [onFollowingChange]); const follow = useCallback(() => { - jumping.current = false; if (!followingNow.current) onFollowingChange?.(true); followingNow.current = true; setFollowing(true); setAway(false); }, [onFollowingChange]); + const confirm = useCallback((version: number) => { + requestAnimationFrame(() => { + if (settled.current) return; + attempts.current += 1; + if ((version === sizes.current && hasRows.current) || attempts.current >= SETTLE_FRAMES) { + settled.current = true; + setReady(true); + } + }); + }, []); + + const pin = useCallback(() => { + if (frame.current !== undefined) return; + if (!followingNow.current && settled.current) return; + frame.current = requestAnimationFrame(() => { + frame.current = undefined; + if (followingNow.current && metrics.current.layout > 0) scrollToLatest(false); + if (!settled.current) confirm(sizes.current); + }); + }, [scrollToLatest, confirm]); + const settle = useCallback( - (offset: number) => { - if (!atLatest(offset)) return; - if (offset > 0) list.current?.scrollToOffset({ offset: 0, animated: true }); + (distance: number) => { + if (!atLatest(distance)) return; + if (distance > 0) scrollToLatest(true); follow(); }, - [list, follow], + [scrollToLatest, follow], ); const onScroll = useCallback( (event: ScrollEvent) => { - const offset = event.nativeEvent.contentOffset.y; - if (!atLatest(offset) && !jumping.current) { - setAway(true); - hold(); - } + measureEvent(event); }, - [hold], + [measureEvent], ); const onScrollBeginDrag = useCallback(() => { dragging.current = true; - jumping.current = false; hold(); }, [hold]); const onScrollEndDrag = useCallback( (event: ScrollEvent) => { dragging.current = false; - if ((event.nativeEvent.velocity?.y ?? 0) === 0) settle(event.nativeEvent.contentOffset.y); + const distance = measureEvent(event); + if ((event.nativeEvent.velocity?.y ?? 0) === 0) settle(distance); }, - [settle], + [measureEvent, settle], ); const onMomentumScrollEnd = useCallback( (event: ScrollEvent) => { + const distance = measureEvent(event); if (dragging.current) return; - jumping.current = false; - settle(event.nativeEvent.contentOffset.y); + settle(distance); }, - [settle], + [measureEvent, settle], ); - const jumpToLatest = useCallback(() => { - follow(); - jumping.current = true; - list.current?.scrollToOffset({ offset: 0, animated: true }); - }, [list, follow]); + const onContentSizeChange = useCallback( + (_width: number, height: number) => { + metrics.current = { ...metrics.current, content: height }; + sizes.current += 1; + pin(); + }, + [pin], + ); - const maintainVisibleContentPosition = useMemo( - () => (following ? undefined : { minIndexForVisible: 0 }), - [following], + const onLayout = useCallback( + (event: LayoutChangeEvent) => { + metrics.current = { ...metrics.current, layout: event.nativeEvent.layout.height }; + pin(); + }, + [pin], ); + const jumpToLatest = useCallback(() => { + follow(); + scrollToLatest(true); + }, [scrollToLatest, follow]); + return { away, + ready, unseen: following ? 0 : unseenSince(messages, anchor), + hold, jumpToLatest, listProps: { onScroll, + onScrollToTop: hold, onScrollBeginDrag, onScrollEndDrag, onMomentumScrollEnd, - maintainVisibleContentPosition, + onContentSizeChange, + onLayout, + maintainVisibleContentPosition: KEEP_VISIBLE, }, }; } diff --git a/apps/mobile/src/ui/chat/header-chrome.tsx b/apps/mobile/src/ui/chat/header-chrome.tsx index 9d4153deaa..7c84e4c23b 100644 --- a/apps/mobile/src/ui/chat/header-chrome.tsx +++ b/apps/mobile/src/ui/chat/header-chrome.tsx @@ -20,52 +20,18 @@ export function useChatHeaderHeight(): number { ); } -export function ChatHeader({ - left, - center, - right, -}: { - left: ReactNode; - center: ReactNode; - right: ReactNode; -}) { - const theme = useDesignTheme(); - const insets = useSafeAreaInsets(); - return ( - - {left} - - {center} - - {right} - - ); -} - export function HeaderCircleButton({ label, onPress, children, selected = false, + plain = false, }: { label: string; onPress(): void; children: ReactNode; selected?: boolean; + plain?: boolean; }) { const theme = useDesignTheme(); const glass = useLiquidGlass(); @@ -81,10 +47,12 @@ export function HeaderCircleButton({ borderRadius: radius.round, alignItems: 'center', justifyContent: 'center', - ...headerGlassFill(theme, glass, pressed), + ...(plain + ? { backgroundColor: pressed ? theme.layer.raised.pressed : undefined } + : headerGlassFill(theme, glass, pressed)), })} > - {glass ? : null} + {glass && !plain ? : null} {children} ); @@ -103,10 +71,10 @@ export function headerGlassFill(theme: DesignTheme, glass: boolean, pressed: boo }; } -export function HeaderBackButton({ onPress }: { onPress(): void }) { +export function HeaderBackButton({ onPress, plain }: { onPress(): void; plain?: boolean }) { const theme = useDesignTheme(); return ( - + ); diff --git a/apps/mobile/src/ui/chat/native-header.ts b/apps/mobile/src/ui/chat/native-header.ts new file mode 100644 index 0000000000..046a6c2a02 --- /dev/null +++ b/apps/mobile/src/ui/chat/native-header.ts @@ -0,0 +1,13 @@ +import { useHeaderHeight } from 'expo-router/react-navigation'; +import { Platform, PlatformColor, type TextStyle } from 'react-native'; + +export const glassHeader = + Platform.OS === 'ios' && Number.parseInt(String(Platform.Version), 10) >= 26; + +export function useHeaderOverlap(): number { + const height = useHeaderHeight(); + return Platform.OS === 'ios' ? height : 0; +} + +export const barLabelStyle: TextStyle = + Platform.OS === 'ios' ? { color: PlatformColor('label') } : {}; diff --git a/apps/mobile/src/ui/markdown-viewport.ts b/apps/mobile/src/ui/markdown-viewport.ts index 7ad6504130..a7f4922d40 100644 --- a/apps/mobile/src/ui/markdown-viewport.ts +++ b/apps/mobile/src/ui/markdown-viewport.ts @@ -15,7 +15,7 @@ import type { MarkdownViewport } from './markdown-window'; export type MarkdownViewportStage = { viewport: AnimatedRef; - invertedContent: AnimatedRef; + content: AnimatedRef; scroll: SharedValue; layout: SharedValue; active: boolean; @@ -56,7 +56,7 @@ export function useMarkdownViewport() { ); const scroll = stage?.scroll; const viewport = stage?.viewport; - const content = stage?.invertedContent; + const content = stage?.content; const layout = stage?.layout; const active = stage?.active === true; useAnimatedReaction( @@ -76,8 +76,7 @@ export function useMarkdownViewport() { if (document === null || frame === null || contentFrame === null || frame.height <= 0) return null; return { - top: - contentFrame.pageY - document.pageY + contentFrame.height - frame.height - scroll.value, + top: contentFrame.pageY - document.pageY + scroll.value, height: frame.height, }; }, diff --git a/apps/mobile/src/ui/open-full-client.tsx b/apps/mobile/src/ui/open-full-client.tsx index b9e090821c..57f6564c50 100644 --- a/apps/mobile/src/ui/open-full-client.tsx +++ b/apps/mobile/src/ui/open-full-client.tsx @@ -9,16 +9,16 @@ import { useDesignTheme } from './design/theme'; export function OpenFullClientButton({ target, - round = false, + bar = false, }: { target: FullClientTarget; - round?: boolean; + bar?: boolean; }) { const router = useRouter(); const open = () => router.push({ pathname: '/full-client', params: fullClientParams(target) }); - if (round) + if (bar) return ( - + ); diff --git a/apps/mobile/src/ui/primitives/keyboard-frame.android.tsx b/apps/mobile/src/ui/primitives/keyboard-frame.android.tsx index 4c17764138..5da80ed35b 100644 --- a/apps/mobile/src/ui/primitives/keyboard-frame.android.tsx +++ b/apps/mobile/src/ui/primitives/keyboard-frame.android.tsx @@ -8,6 +8,10 @@ import { import type { KeyboardChatFrameProps } from './keyboard-frame'; +export function useListBottomSafeArea(): number { + return 0; +} + export function KeyboardRoot({ children }: { children: ReactNode }) { return ( diff --git a/apps/mobile/src/ui/primitives/keyboard-frame.tsx b/apps/mobile/src/ui/primitives/keyboard-frame.tsx index 6b3bb8f86c..c1a1a24790 100644 --- a/apps/mobile/src/ui/primitives/keyboard-frame.tsx +++ b/apps/mobile/src/ui/primitives/keyboard-frame.tsx @@ -1,6 +1,9 @@ import type { ReactNode } from 'react'; import { KeyboardAvoidingView, View, type ColorValue, type LayoutChangeEvent } from 'react-native'; -import { KeyboardProvider } from 'react-native-keyboard-controller'; +import { KeyboardProvider, useKeyboardState } from 'react-native-keyboard-controller'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { space } from '../design/tokens'; export interface KeyboardChatFrameProps { children: ReactNode; @@ -11,6 +14,12 @@ export interface KeyboardChatFrameProps { onComposerLayout(event: LayoutChangeEvent): void; } +export function useListBottomSafeArea(): number { + const bottom = useSafeAreaInsets().bottom; + const open = useKeyboardState((state) => state.isVisible); + return open ? 0 : bottom; +} + export function KeyboardRoot({ children }: { children: ReactNode }) { return {children}; } @@ -23,10 +32,15 @@ export function KeyboardChatFrame({ onMount, onComposerLayout, }: KeyboardChatFrameProps) { + const bottom = useSafeAreaInsets().bottom; return ( {background} - + {children} ({ vi.mock('../src/host/native-source-drafts', () => ({ useSourceDraft: () => ['', () => {}] })); vi.mock('expo-router', () => ({ useIsFocused: () => true, - Stack: { Screen: () => null }, + Stack: { + Screen: ({ + options, + }: { + options: { + headerLeft?: () => ReactNode; + headerBackVisible?: boolean; + unstable_headerLeftItems?: () => Array<{ + type: string; + element?: ReactNode; + label?: string; + onPress?: () => void; + }>; + }; + }) => ( +
+ {options.unstable_headerLeftItems?.().map((item, index) => ( + + {item.type === 'button' ? ( + + ) : ( + item.element + )} + + )) ?? + options.headerLeft?.() ?? + null} +
+ ), + }, useRouter: () => ({ canGoBack: () => state.history.length > 1, back: () => state.history.pop(), @@ -65,8 +96,12 @@ vi.mock('../src/host/pick-files', () => ({ MAX_PICKED: 10, pickFiles: vi.fn() }) vi.mock('../src/host/speech', () => ({ useDictation: () => ({ state: { status: 'idle' }, mountRef: () => undefined }), })); +vi.mock('../src/ui/chat/native-header', () => ({ + glassHeader: true, + barLabelStyle: {}, + useHeaderOverlap: () => 80, +})); vi.mock('../src/ui/chat/header-chrome', () => ({ - ChatHeader: ({ left }: { left: ReactNode }) =>
{left}
, HeaderBackButton: ({ onPress }: { onPress(): void }) => ( + ), + EdgeFade: ({ edge, height }: { edge: string; height: number }) => ( + + ), +})); +vi.mock('../src/ui/primitives/keyboard-frame', () => ({ + useListBottomSafeArea: () => 0, + KeyboardChatFrame: ({ + children, + composer, + background, + }: { + children: ReactNode; + composer: ReactNode; + background?: ReactNode; + }) => ( +
+
{background === undefined ? 'none' : 'view'}
+
{children}
+
{composer}
+
+ ), +})); +vi.mock('../src/ui/chat/speaker-row', async () => { + const { createContext } = await import('react'); + return { + SpeakerStageContext: createContext(undefined), + createSpeakerRegistry: () => ({ setGroups() {} }), + }; +}); +vi.mock('../src/ui/chat/approval-card', () => ({ ApprovalCard: () => null })); +vi.mock('../src/ui/chat/activity-row', () => ({ ActivityRow: () => null })); +vi.mock('../src/ui/chat/question-card', () => ({ QuestionCard: () => null })); +vi.mock('../src/ui/chat/channel-title', () => ({ ChannelTitle: () => null })); +vi.mock('../src/ui/chat/chat-title', () => ({ ChatTitle: () => null })); +vi.mock('../src/ui/chat/composer', () => ({ Composer: () => null })); +vi.mock('../src/ui/chat/image-viewer', () => ({ ImageViewer: () => null })); +vi.mock('../src/ui/chat/jump-to-latest', () => ({ JumpToLatest: () => null })); +vi.mock('../src/ui/chat/message-actions', () => ({ MessageActions: () => null })); +vi.mock('../src/ui/chat/message-bubble', async () => { + const { memo, useCallback, useSyncExternalStore } = await import('react'); + return { + PlaybackBubble: memo(function PlaybackBubble({ + queueKey, + message, + }: { + queueKey: string; + message?: { body: string }; + }) { + counts.rows.set(queueKey, (counts.rows.get(queueKey) ?? 0) + 1); + const subscribe = useCallback( + (listener: () => void) => stream.playback.subscribeBubble(queueKey, listener), + [queueKey], + ); + const text = useSyncExternalStore(subscribe, () => stream.playback.getBubble(queueKey)); + return {text ?? message?.body}; + }), + }; +}); +vi.mock('../src/ui/chat/message-menu', () => ({ copyMessage: vi.fn(), shareText: vi.fn() })); +vi.mock('../src/ui/chat/mention-suggestions', () => ({ MentionSuggestions: () => null })); +vi.mock('../src/ui/chat/working-status', () => ({ + WorkingStatus: ({ bots }: { bots: RosterBot[] }) => + bots.length ? pending : null, +})); +vi.mock('../src/ui/chat/time-separator', () => ({ TimeSeparator: () => null })); +vi.mock('../src/ui/open-full-client', () => ({ OpenFullClientButton: () => null })); + +import { ConversationScreen } from '../src/ui/chat/conversation-screen'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +it('re-renders only the streaming row when a draft token arrives', async () => { + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + await act(async () => + root.render(), + ); + const draft = container.querySelector('[data-testid="draft:d1"]'); + expect(draft).not.toBeNull(); + const listRenders = counts.renderItem; + const neighbour = counts.rows.get('m2'); + const streaming = counts.rows.get('draft:d1')!; + expect(listRenders).toBeGreaterThan(0); + expect(neighbour).toBeGreaterThan(0); + for (const token of ['Hel', 'lo', ' there']) + await act(async () => stream.append('draft:d1', token)); + expect(draft?.textContent).toBe('Hello there'); + expect(counts.rows.get('draft:d1')).toBe(streaming + 3); + expect(counts.rows.get('m2')).toBe(neighbour); + expect(counts.renderItem).toBe(listRenders); + expect(stream.timelineListeners.size).toBeGreaterThan(0); + expect( + [...container.querySelectorAll('[data-testid]')] + .map((node) => node.getAttribute('data-testid')) + .filter((id) => id === 'm1' || id === 'm2' || id === 'draft:d1'), + ).toEqual(['m1', 'm2', 'draft:d1']); + await act(async () => root.unmount()); + container.remove(); +}); diff --git a/apps/mobile/test/follow-latest-gestures.test.tsx b/apps/mobile/test/follow-latest-gestures.test.tsx index 40cd6590e1..7fb07fbdd6 100644 --- a/apps/mobile/test/follow-latest-gestures.test.tsx +++ b/apps/mobile/test/follow-latest-gestures.test.tsx @@ -1,18 +1,76 @@ // @vitest-environment jsdom import { act } from 'react'; import { createRoot } from 'react-dom/client'; -import type { FlatList, NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; -import { describe, expect, it, vi } from 'vitest'; +import type { + FlatList, + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, +} from 'react-native'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useFollowLatest } from '../src/ui/chat/follow-latest'; +import type { ConversationMessage } from '../src/host/conversation'; +import { SETTLE_FRAMES, useFollowLatest } from '../src/ui/chat/follow-latest'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -const event = (offset: number, velocity = 0) => +const CONTENT = 2000; +const LAYOUT = 800; +const END = CONTENT - LAYOUT; + +const event = (distance: number, velocity = 0, content = CONTENT) => ({ - nativeEvent: { contentOffset: { y: offset }, velocity: { y: velocity } }, + nativeEvent: { + contentOffset: { y: content - LAYOUT - distance }, + contentSize: { height: content }, + layoutMeasurement: { height: LAYOUT }, + velocity: { y: velocity }, + }, }) as NativeSyntheticEvent; +const layout = (height: number) => ({ nativeEvent: { layout: { height } } }) as LayoutChangeEvent; + +let frames: Array<() => void> = []; +const flushFrames = () => + act(() => { + for (let round = 0; round < 4 && frames.length > 0; round += 1) + for (const callback of frames.splice(0)) callback(); + }); + +beforeEach(() => { + frames = []; + vi.stubGlobal('requestAnimationFrame', (callback: () => void) => frames.push(callback)); + vi.stubGlobal('cancelAnimationFrame', () => {}); +}); +afterEach(() => vi.unstubAllGlobals()); + +function message(id: string, author: ConversationMessage['author']): ConversationMessage { + return { id, at: '', author, body: id, attachments: [], delivery: 'committed' }; +} + +function mount(messages: readonly ConversationMessage[] = [], start = 0) { + const following = vi.fn(); + const scrollToOffset = vi.fn(); + const list = { current: { scrollToOffset } as unknown as FlatList }; + const probe: { current?: ReturnType> } = {}; + function Probe({ rows }: { rows: readonly ConversationMessage[] }) { + probe.current = useFollowLatest(list, rows, following, start); + return null; + } + const root = createRoot(document.createElement('div')); + act(() => root.render()); + return { + following, + scrollToOffset, + get current() { + return probe.current!; + }, + rerender: (rows: readonly ConversationMessage[]) => + act(() => root.render()), + unmount: () => act(() => root.unmount()), + }; +} + describe('history-reading gesture ownership', () => { it('keeps a replacement playback controller held when display mode changes in history', () => { const first = vi.fn(); @@ -34,39 +92,174 @@ describe('history-reading gesture ownership', () => { }); it('holds immediately and ignores a near-bottom layout event until a return gesture ends', () => { - const following = vi.fn(); - const scrollToOffset = vi.fn(); - const list = { current: { scrollToOffset } as unknown as FlatList }; - let current!: ReturnType>; - function Probe() { - current = useFollowLatest(list, [], following); - return null; - } - const root = createRoot(document.createElement('div')); - act(() => root.render()); - act(() => current.listProps.onScrollBeginDrag()); - expect(following.mock.calls).toEqual([[false]]); - expect(current.away).toBe(true); + const view = mount(); + act(() => view.current.listProps.onScrollBeginDrag()); + expect(view.following.mock.calls).toEqual([[false]]); + expect(view.current.away).toBe(true); act(() => { - current.listProps.onScroll(event(180)); - current.listProps.onScrollEndDrag(event(180, 1)); - current.listProps.onScroll(event(0)); - current.listProps.onMomentumScrollEnd(event(180)); + view.current.listProps.onScroll(event(180)); + view.current.listProps.onScrollEndDrag(event(180, 1)); + view.current.listProps.onScroll(event(0)); + view.current.listProps.onMomentumScrollEnd(event(180)); }); - expect(following.mock.calls).toEqual([[false]]); - expect(current.away).toBe(true); - expect(scrollToOffset).not.toHaveBeenCalled(); + expect(view.following.mock.calls).toEqual([[false]]); + expect(view.current.away).toBe(true); + expect(view.scrollToOffset).not.toHaveBeenCalled(); act(() => { - current.listProps.onScrollBeginDrag(); - current.listProps.onScroll(event(0)); - current.listProps.onScrollEndDrag(event(0)); + view.current.listProps.onScrollBeginDrag(); + view.current.listProps.onScroll(event(0)); + view.current.listProps.onScrollEndDrag(event(0)); }); - expect(following.mock.calls).toEqual([[false], [true]]); - expect(current.away).toBe(false); - act(() => current.listProps.onScrollBeginDrag()); - act(() => current.jumpToLatest()); - expect(following.mock.calls).toEqual([[false], [true], [false], [true]]); - expect(scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: true }); - act(() => root.unmount()); + expect(view.following.mock.calls).toEqual([[false], [true]]); + expect(view.current.away).toBe(false); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.jumpToLatest()); + expect(view.following.mock.calls).toEqual([[false], [true], [false], [true]]); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: true }); + view.unmount(); + }); + + it('settles a release just short of the end onto the latest message', () => { + const view = mount(); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.listProps.onScrollEndDrag(event(30))); + expect(view.current.away).toBe(false); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: true }); + view.unmount(); + }); +}); + +describe('following a growing conversation', () => { + it('pins growth to the end at most once per frame without animation while following', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => { + view.current.listProps.onContentSizeChange(0, CONTENT); + view.current.listProps.onContentSizeChange(0, CONTENT + 20); + view.current.listProps.onContentSizeChange(0, CONTENT + 40); + }); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + flushFrames(); + expect(view.scrollToOffset.mock.calls).toEqual([[{ offset: END + 40, animated: false }]]); + view.unmount(); + }); + + it('never moves the viewport for growth after a drag past the slop', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + view.scrollToOffset.mockClear(); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.listProps.onScroll(event(120))); + act(() => view.current.listProps.onScrollEndDrag(event(120))); + for (const grown of [CONTENT + 50, CONTENT + 100, CONTENT + 400]) { + act(() => view.current.listProps.onContentSizeChange(0, grown)); + flushFrames(); + } + expect(view.scrollToOffset).not.toHaveBeenCalled(); + expect(view.current.away).toBe(true); + view.unmount(); + }); + + it('counts unseen Bot messages while away and jumps back to the end', () => { + const first = [message('a', 'bot'), message('b', 'human')]; + const view = mount(first); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.listProps.onScrollEndDrag(event(300))); + view.rerender([...first, message('c', 'bot'), message('d', 'system'), message('e', 'human')]); + expect(view.current.unseen).toBe(2); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 200)); + act(() => view.current.jumpToLatest()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 200, animated: true }); + expect(view.current.away).toBe(false); + expect(view.current.unseen).toBe(0); + view.unmount(); + }); + + it('does not count its own animated jump as leaving the latest message', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.listProps.onScrollEndDrag(event(600))); + act(() => view.current.jumpToLatest()); + act(() => { + view.current.listProps.onScroll(event(500)); + view.current.listProps.onScroll(event(200)); + }); + expect(view.current.away).toBe(false); + act(() => view.current.listProps.onMomentumScrollEnd(event(0))); + act(() => view.current.listProps.onScroll(event(200))); + expect(view.current.away).toBe(false); + act(() => view.current.listProps.onScrollBeginDrag()); + expect(view.current.away).toBe(true); + view.unmount(); + }); + + it('opens at the newest message when the list reports a scroll at the top before it is pinned', () => { + const view = mount([message('a', 'bot'), message('b', 'human')], 100); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onScroll(event(CONTENT - LAYOUT + 100))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT / 2)); + act(() => view.current.listProps.onScroll(event(CONTENT - LAYOUT, 0, CONTENT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); + expect(view.current.away).toBe(false); + expect(view.current.ready).toBe(true); + expect(view.following).not.toHaveBeenCalled(); + view.unmount(); + }); + + it('leaves the latest message when the status bar scrolls the list to the top', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + view.scrollToOffset.mockClear(); + act(() => view.current.listProps.onScrollToTop()); + expect(view.current.away).toBe(true); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 100)); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + view.unmount(); + }); + + it('reveals the list only once the end has been pinned and the content settled', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + expect(view.current.ready).toBe(false); + act(() => frames.shift()!()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 300)); + act(() => frames.shift()!()); + expect(view.current.ready).toBe(false); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 300, animated: false }); + expect(view.current.ready).toBe(true); + view.unmount(); + }); + + it('gives up waiting for stable content after a bounded number of frames', () => { + const view = mount([]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + for (let frame = 0; frame < SETTLE_FRAMES; frame += 1) { + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + frame)); + flushFrames(); + } + expect(view.current.ready).toBe(true); + view.unmount(); + }); + + it('keeps a short conversation under the header instead of scrolling past its start', () => { + const view = mount([message('a', 'bot')], 100); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, LAYOUT - 100)); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: -100, animated: false }); + act(() => view.current.listProps.onContentSizeChange(0, LAYOUT - 300)); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: -100, animated: false }); + view.unmount(); }); }); diff --git a/apps/mobile/test/follow-latest.test.tsx b/apps/mobile/test/follow-latest.test.tsx index 37d33991b7..23b8c6dfec 100644 --- a/apps/mobile/test/follow-latest.test.tsx +++ b/apps/mobile/test/follow-latest.test.tsx @@ -32,7 +32,8 @@ vi.mock('react-native', async () => { }); vi.mock('react-native-svg', () => ({ default: () => null, Path: () => null })); -const { atLatest, unseenSince } = await import('../src/ui/chat/follow-latest'); +const { atLatest, distanceFromLatest, latestOffset, unseenSince } = + await import('../src/ui/chat/follow-latest'); const { JumpToLatest } = await import('../src/ui/chat/jump-to-latest'); const { t } = await import('../src/ui/strings'); @@ -48,13 +49,27 @@ function message(id: string, author: ConversationMessage['author']): Conversatio } describe('following the latest message', () => { - it('treats the first few points of the inverted list as the latest', () => { + it('treats the last few points above the end of the list as the latest', () => { expect(atLatest(0)).toBe(true); expect(atLatest(-12)).toBe(true); expect(atLatest(40)).toBe(true); expect(atLatest(41)).toBe(false); }); + it('measures the distance from the end of a top-to-bottom list', () => { + expect(distanceFromLatest({ content: 2000, layout: 800, offset: 1200 })).toBe(0); + expect(distanceFromLatest({ content: 2000, layout: 800, offset: 1000 })).toBe(200); + expect(distanceFromLatest({ content: 2000, layout: 800, offset: 1234 })).toBe(-34); + expect(distanceFromLatest({ content: 700, layout: 800, offset: -100 })).toBe(0); + }); + + it('targets the end offset without scrolling above the header inset', () => { + expect(latestOffset({ content: 2000, layout: 800 }, 100)).toBe(1200); + expect(latestOffset({ content: 700, layout: 800 }, 100)).toBe(-100); + expect(latestOffset({ content: 500, layout: 800 }, 100)).toBe(-100); + expect(latestOffset({ content: 500, layout: 800 }, 0)).toBe(0); + }); + it('counts the Bot messages that arrived after the Human left the latest', () => { const messages = [ message('a', 'bot'), diff --git a/apps/mobile/test/keyboard-frame.test.tsx b/apps/mobile/test/keyboard-frame.test.tsx new file mode 100644 index 0000000000..7a8424fe32 --- /dev/null +++ b/apps/mobile/test/keyboard-frame.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { describe, expect, it, vi } from 'vitest'; + +const keyboard = vi.hoisted(() => ({ visible: false })); +vi.mock('react-native', () => import('./react-native-shim')); +vi.mock('react-native-keyboard-controller', () => ({ + KeyboardProvider: () => null, + useKeyboardState: (select: (state: { isVisible: boolean }) => T) => + select({ isVisible: keyboard.visible }), +})); +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 59, bottom: 34, left: 0, right: 0 }), +})); + +import { useListBottomSafeArea } from '../src/ui/primitives/keyboard-frame'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function measure() { + const seen: number[] = []; + function Probe() { + seen.push(useListBottomSafeArea()); + return null; + } + const root = createRoot(document.createElement('div')); + act(() => root.render()); + act(() => root.unmount()); + return seen.at(-1); +} + +describe('list bottom safe area', () => { + it('is the home indicator inset while the keyboard is closed', () => { + keyboard.visible = false; + expect(measure()).toBe(34); + }); + + it('is zero while the keyboard covers the home indicator', () => { + keyboard.visible = true; + expect(measure()).toBe(0); + }); +}); diff --git a/apps/mobile/test/markdown-viewport.test.tsx b/apps/mobile/test/markdown-viewport.test.tsx index 11713364c6..060132a69a 100644 --- a/apps/mobile/test/markdown-viewport.test.tsx +++ b/apps/mobile/test/markdown-viewport.test.tsx @@ -7,7 +7,7 @@ const native = vi.hoisted(() => ({ frames: [ { pageY: 0, height: 834 }, { pageY: -2051, height: 19418 }, - { pageY: 17036, height: 222 }, + { pageY: 10449, height: 222 }, ], nextRef: 0, react: () => {}, @@ -84,7 +84,7 @@ function Conversation() { const scroll = useSharedValue(16533); const layout = useSharedValue(1); native.scroll = scroll; - const stage = { viewport, invertedContent: content, scroll, layout, active: true }; + const stage = { viewport, content, scroll, layout, active: true }; return ( @@ -96,7 +96,7 @@ it('restores blocks on the last return scroll event even while Fabric still meas native.frames = [ { pageY: 0, height: 834 }, { pageY: -2051, height: 19418 }, - { pageY: 17036, height: 222 }, + { pageY: 10449, height: 222 }, ]; const container = document.createElement('div'); document.body.append(container); @@ -119,10 +119,10 @@ it('restores blocks on the last return scroll event even while Fabric still meas window.measure(block, index < blocks.length - 1, 100); await flush(); expect(window.project(blocks, read()).some((part) => part.kind === 'space')).toBe(true); - native.scroll.value = 0; - native.frames[1] = { pageY: -6486, height: 19418 }; - native.frames[2] = { pageY: 12601, height: 222 }; + native.scroll.value = 12000; + native.frames[1] = { pageY: -12000, height: 19418 }; + native.frames[2] = { pageY: 500, height: 222 }; await flush(); expect(window.project(blocks, read()).every((part) => part.kind === 'block')).toBe(true); - expect(read().top).toBe(-503); + expect(read().top).toBe(-500); }); diff --git a/apps/mobile/test/reanimated-shim.ts b/apps/mobile/test/reanimated-shim.ts index c50e1d3a38..0cb95983f9 100644 --- a/apps/mobile/test/reanimated-shim.ts +++ b/apps/mobile/test/reanimated-shim.ts @@ -9,16 +9,24 @@ const FlatList = (props: { keyExtractor(row: unknown): string; renderItem(info: { item: unknown }): ReactNode; ListHeaderComponent?: ReactNode; + ListFooterComponent?: ReactNode; ListEmptyComponent?: ReactNode; + contentInset?: { bottom?: number }; + keyboardDismissMode?: string; }) => createElement( 'div', - null, + { + 'data-testid': 'chat-list', + 'data-inset-bottom': props.contentInset?.bottom, + 'data-dismiss': props.keyboardDismissMode, + }, props.ListHeaderComponent ?? null, props.data.length === 0 ? (props.ListEmptyComponent ?? null) : null, ...props.data.map((row) => createElement(Fragment, { key: props.keyExtractor(row) }, props.renderItem({ item: row })), ), + props.ListFooterComponent ?? null, ); const View = (props: Props) => createElement('div', null, props.children); diff --git a/changes/2344-native-chat-header.md b/changes/2344-native-chat-header.md new file mode 100644 index 0000000000..a88eb3ae70 --- /dev/null +++ b/changes/2344-native-chat-header.md @@ -0,0 +1,11 @@ +--- +section: Changed +--- + +## en + +- The mobile chat uses the system navigation header. On iOS it has no bar of its own: a round Liquid Glass back button, then a separate glass capsule with the Bot's avatar and name that opens its profile, and a round button on the right for the Bot's computer; messages scrolling under it soften with the system scroll edge effect. Android shows the same items in a Material top app bar. The message list now runs top to bottom, opens at the newest message and keeps its place while older history loads, the composer sits just above the keyboard without an extra gap, and dragging the messages puts the keyboard away. ([#2344](https://github.com/BotHarness/DeepSeekBot/pull/2344)) + +## zh + +- 手机端聊天页改用系统导航栏。iOS 上导航栏没有底色:左侧是圆形液态玻璃返回按钮,紧挨着一枚独立的玻璃胶囊,显示 Bot 头像和名字,点按打开 Bot 资料;右侧一个圆形按钮打开 Bot 的电脑;消息滚到导航栏下方时由系统边缘效果柔和淡出。Android 在 Material 顶部应用栏中显示同样的内容。消息列表改为自上而下排列,打开时停在最新消息,加载更早的历史时当前位置保持不动;键盘弹出时输入框紧贴键盘上方,不再留出多余空隙;拖动消息即可收起键盘。([#2344](https://github.com/BotHarness/DeepSeekBot/pull/2344))