Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ The two-tab shell follows [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-
- 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. On iOS 26 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 composer island. On iOS the **+** control and field share one taller rounded surface; on iOS 26 with Reduce Transparency off it is Liquid Glass. Older iOS and Reduce Transparency give it 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. On Android the field is a floating island like the header's (Human decision, 2026-11-11, after Telegram Android): the same hairline rim, one-point inner top highlight and soft drop shadow (`chatComposerSurface`), drawn with React Native `boxShadow` so nothing is captured or blurred. The round jump-to-latest button above it is the same island, in place of its Material elevation. 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 and Android the bottom edge is a drawn mask layer between the list and the composer, like Grok's chat: one soft curve (`(1 − d)^edgeFade.curve`, sampled `edgeFade.samples` times) painted in the chat's own background colour (`base` with an image background). On Android the bottom edge is not held solid: it uses the header's curve (`chatComposerSurface.fade`), from `peak` at the screen edge to transparent `edgeFade.bottom` above the capsule, so messages show faintly beside and below the floating composer as in Telegram. On iOS, at the bottom it is held solid from the screen edge up to `space.x2` below the top of the composer's field capsule, whatever the draft's line count, then the curve clears over `edgeFade.bottom` just above the capsule, so messages stay readable right up to the composer and are hidden under it. At the top (Android's floating header, and iOS before 26) it is held solid across the status-bar safe area, then the curve clears `edgeFade.reach` below the header. iOS 26 hides UIKit's bottom scroll edge effect for the chat (`scrollEdgeEffects.bottom: 'hidden'`) and does not use a `UIScrollEdgeElementContainerInteraction` on the composer: UIKit picked the hard style there, an opaque band in a different colour from the list with a hairline across its top, at a height that did not follow a wrapped draft, and neither re-adding the interaction nor pinning the `soft` style fixed it. The top edge stays UIKit's automatic scroll edge effect. 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:
- **Swipe left to reply; swipe right is always Back** (Human decision, 2026-11-03, from user feedback, replacing the swipe right to reply: a swipe right is the system Back on iOS, so a bubble must never claim it). A swipe left on a committed Human or Bot message (not a pending or failed one, a card, an activity row, a notice or the welcome) moves its row with the finger, up to `swipeReply.reach` (72pt) and then with resistance, while a round reply glyph fades and grows in behind its trailing edge. Passing `swipeReply.commit` (56pt) gives one `selection` haptic; letting go there starts a reply, and the row springs back either way. The recognizer starts only leftward after `swipeReply.start` (12pt), fails on 10pt of vertical travel and on `swipeReply.backFail` (8pt) of rightward travel, so a swipe right that starts on a bubble is still Back. A replyable row carries the native marker `horizontal-swipe-owner` (`nativeID`), and the containers that turn a swipe left into the profile skip a drag that starts on it: the iOS chat/profile pager (a gate recognizer in our `react-native-pager-view` patch that the page drag requires to fail) and the Android profile side sheet (`EndSheet`, which already skipped horizontally scrollable targets). A swipe left anywhere else in the chat opens the profile. The chat screens enable the iOS full-screen back swipe (`fullScreenGestureEnabled: true`), and the same pager gate leaves a swipe right on the chat page to that back swipe; on the profile page a swipe right returns to the chat. Android Back stays the system gesture, which the chat no longer competes with. RTL mirrors both directions. Starting a reply focuses the composer and shows a reply bar above it (on Android inside the composer island, above the text row): a reply glyph, "Replying to {name}" ("You" for the Human's own message) and one line of the message, with ✕ (Cancel reply) to drop it. Sending carries `replyTo` to the Host's `channelSend`, which refuses a target outside the Channel; the bar clears on send. The swipe is off while selecting messages, without a composer (Bot-to-Bot DMs) and while the Host is not connected. CLI: `deepseekbot send <bot> --body … --reply-to <message-id>`.
- **Swipe left to reply; swipe right is always Back** (Human decision, 2026-11-03, from user feedback, replacing the swipe right to reply: a swipe right is the system Back on iOS, so a bubble must never claim it). A swipe left on a committed Human or Bot message (not a pending or failed one, a card, an activity row, a notice or the welcome) moves its row with the finger, up to `swipeReply.reach` (72pt) and then with resistance, while a round reply glyph fades and grows in behind its trailing edge. Passing `swipeReply.commit` (56pt) gives one `selection` haptic; letting go there starts a reply, and the row springs back either way. A drag that starts on a horizontally scrollable part of the bubble (a code block or a table) scrolls it instead: each such scroller sits in a native gesture that blocks the reply pan (`HorizontalScroll`). The recognizer starts only leftward after `swipeReply.start` (12pt), fails on 10pt of vertical travel and on `swipeReply.backFail` (8pt) of rightward travel, so a swipe right that starts on a bubble is still Back. A replyable row carries the native marker `horizontal-swipe-owner` (`nativeID`), and the containers that turn a swipe left into the profile skip a drag that starts on it: the iOS chat/profile pager (a gate recognizer in our `react-native-pager-view` patch that the page drag requires to fail) and the Android profile side sheet (`EndSheet`, which already skipped horizontally scrollable targets). A swipe left anywhere else in the chat opens the profile. The chat screens enable the iOS full-screen back swipe (`fullScreenGestureEnabled: true`), and the same pager gate leaves a swipe right on the chat page to that back swipe; on the profile page a swipe right returns to the chat. Android Back stays the system gesture, which the chat no longer competes with. RTL mirrors both directions. Starting a reply focuses the composer and shows a reply bar above it (on Android inside the composer island, above the text row): a reply glyph, "Replying to {name}" ("You" for the Human's own message) and one line of the message, with ✕ (Cancel reply) to drop it. Sending carries `replyTo` to the Host's `channelSend`, which refuses a target outside the Channel; the bar clears on send. The swipe is off while selecting messages, without a composer (Bot-to-Bot DMs) and while the Host is not connected. CLI: `deepseekbot send <bot> --body … --reply-to <message-id>`.
- A reply shows its quote as a tinted block at the top of its bubble (option B2 of #3857, Telegram-style): a `swipeReply.bar` (3pt) bar down its leading edge and the quoted author's name in `meta` strong in that author's colour, then one line of the original in `meta` (three lines at large text sizes), from the Host's `replyToPreview`. The author colour is the Bot's primary colour (`botColor`) and `accent` for the Human, both put through the shared `botNameColor` contrast check against the bubble; a bridged author uses `fg`. The block's fill is that colour mixed 13% (`replyQuote.tint`) into the bubble's own fill, as an opaque colour, at radius `sm`. On the Human's `accent` bubble the bar, name and fill use `onAccent` instead, so the quote stays readable on any Bot's colour. Tapping the block jumps to the original (the same scroll-and-load path as a notification's linked message); screen readers hear it as one button, "Reply to {name}: {text}", and reach the jump through the bubble's "Shows the original message" action. A reply whose original is gone reads "Original message unavailable" in a `muted` block that is not a button. The pending copy of a reply quotes the loaded message until the Host's entry arrives. When the preview carries a media hint, the line says what was quoted: an image shows a 32pt thumbnail (`replyThumb`, radius `xs`) beside the name and its caption, or "Photo" / "3 photos" without one; voice reads "Voice · 0:07" ("语音 · 0:07"); a file without text reads "File · Q3.pdf".
- 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. When the actor has no avatar (a Human, such as a Human stopping a Session), the kind icon itself sits in the speaker gutter and is not repeated, so the row shows one symbol. Stopped turns use an outlined stop-in-circle, the same symbol as the Stop item in the ⋯ menu.
- 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.
Expand Down
19 changes: 17 additions & 2 deletions apps/mobile/src/ui/chat/follow-latest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const LATEST_SLOP = 40;
export const SETTLE_FRAMES = 45;
export const STABLE_FRAMES = 6;
export const CHECK_BUDGET = SETTLE_FRAMES * 4;
export const NATIVE_GLIDE_MS = 450;
const KEYBOARD_SETTLE_MS = 1000;
export const MOMENTUM_WAIT_MS = 120;

Expand Down Expand Up @@ -100,6 +101,7 @@ export function useFollowLatest<Row>(
const checks = useRef(0);
const keyboardTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const momentumTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const catchUpTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useLayoutEffect(() => {
if (!followingNow.current) onFollowingChange?.(false);
}, [onFollowingChange]);
Expand All @@ -111,6 +113,7 @@ export function useFollowLatest<Row>(
confirmation.current = undefined;
clearTimeout(keyboardTimer.current);
clearTimeout(momentumTimer.current);
clearTimeout(catchUpTimer.current);
},
[],
);
Expand Down Expand Up @@ -224,6 +227,15 @@ export function useFollowLatest<Row>(
pin();
}, [rowsPresent, pin]);

const catchUp = useCallback(() => {
clearTimeout(catchUpTimer.current);
catchUpTimer.current = setTimeout(() => {
catchUpTimer.current = undefined;
if (!followingNow.current || dragging.current || keyboardMoving.current) return;
if (distanceFromLatest(metrics.current, inverted) > 1) scrollToLatest(true);
}, NATIVE_GLIDE_MS);
}, [inverted, scrollToLatest]);

const onKeyboardStart = useCallback(() => {
keyboardMoving.current = true;
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
Expand Down Expand Up @@ -329,10 +341,13 @@ export function useFollowLatest<Row>(
attempts.current = 0;
if (checks.current < CHECK_BUDGET) setCanRetry(false);
}
if (nativeFollow && settled.current) return;
if (nativeFollow && settled.current) {
if (height !== previous) catchUp();
return;
}
pin(animateScroll && settled.current && height > previous);
},
[pin, animateScroll, nativeFollow],
[pin, animateScroll, nativeFollow, catchUp],
);

const onLayout = useCallback(
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/ui/chat/reply-swipe-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createContext, type ComponentType, type ReactNode } from 'react';

export type SwipeBlocker = ComponentType<{ children: ReactNode }>;

export const ReplySwipeBlocker = createContext<SwipeBlocker | undefined>(undefined);
70 changes: 45 additions & 25 deletions apps/mobile/src/ui/chat/reply-swipe.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react';
import { useCallback, useMemo, useRef, type ReactNode } from 'react';
import { I18nManager, View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
Expand All @@ -12,6 +12,7 @@ import { spring } from '../design/motion';
import { space, swipeReply } from '../design/tokens';
import { haptic } from '../haptics';
import { ReplyIcon } from '../shell/icons';
import { ReplySwipeBlocker, type SwipeBlocker } from './reply-swipe-context';
import { horizontalSwipeOwner, repliesAt, replyTravel } from './reply-swipe-model';

export function ReplySwipe({
Expand All @@ -27,29 +28,44 @@ export function ReplySwipe({
const travel = useSharedValue(0);
const armed = useSharedValue(false);
const direction = I18nManager.isRTL ? 1 : -1;
const arm = () => haptic('selection');
const pan = Gesture.Pan()
.enabled(enabled)
.activeOffsetX(direction * swipeReply.start)
.failOffsetX(-direction * swipeReply.backFail)
.failOffsetY([-swipeReply.verticalFail, swipeReply.verticalFail])
.onStart(() => {
armed.value = false;
})
.onUpdate((event) => {
const x = event.translationX * direction;
travel.value = replyTravel(x);
if (armed.value === repliesAt(x)) return;
armed.value = repliesAt(x);
if (armed.value) runOnJS(arm)();
})
.onEnd((event) => {
if (repliesAt(event.translationX * direction)) runOnJS(onReply)();
})
.onFinalize(() => {
armed.value = false;
travel.value = withSpring(0, spring.spatial.fast);
});
const reply = useRef(onReply);
reply.current = onReply;
const arm = useCallback(() => haptic('selection'), []);
const fire = useCallback(() => reply.current(), []);
const pan = useMemo(
() =>
Gesture.Pan()
.enabled(enabled)
.activeOffsetX(direction * swipeReply.start)
.failOffsetX(-direction * swipeReply.backFail)
.failOffsetY([-swipeReply.verticalFail, swipeReply.verticalFail])
.onStart(() => {
armed.value = false;
})
.onUpdate((event) => {
const x = event.translationX * direction;
travel.value = replyTravel(x);
if (armed.value === repliesAt(x)) return;
armed.value = repliesAt(x);
if (armed.value) runOnJS(arm)();
})
.onEnd((event) => {
if (repliesAt(event.translationX * direction)) runOnJS(fire)();
})
.onFinalize(() => {
armed.value = false;
travel.value = withSpring(0, spring.spatial.fast);
}),
[enabled, direction, arm, fire, armed, travel],
);
const blocker = useMemo<SwipeBlocker>(
() =>
function SwipeBlocker({ children: scroller }: { children: ReactNode }) {
const native = useMemo(() => Gesture.Native().blocksExternalGesture(pan), []);
return <GestureDetector gesture={native}>{scroller}</GestureDetector>;
},
[pan],
);
const moving = useAnimatedStyle(() => ({
transform: [{ translateX: travel.value * direction }],
}));
Expand Down Expand Up @@ -86,7 +102,11 @@ export function ReplySwipe({
<ReplyIcon color={theme.color.muted} size={theme.type.meta.line} />
</View>
</Animated.View>
<Animated.View style={moving}>{children}</Animated.View>
<Animated.View style={moving}>
<ReplySwipeBlocker.Provider value={enabled ? blocker : undefined}>
{children}
</ReplySwipeBlocker.Provider>
</Animated.View>
</View>
</GestureDetector>
);
Expand Down
7 changes: 4 additions & 3 deletions apps/mobile/src/ui/markdown-table.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Tokens } from 'marked';
import { memo, useMemo, type ReactNode } from 'react';
import type { Token } from 'marked';
import { ScrollView, View } from 'react-native';
import { View } from 'react-native';
import { HorizontalScroll } from './primitives/horizontal-scroll';

import { edge, markdownTable, radius, space } from './design/tokens';
import { useDesignTheme } from './design/theme';
Expand Down Expand Up @@ -107,7 +108,7 @@ export function MarkdownTable({
);
const align = JSON.stringify(table.align);
return (
<ScrollView horizontal style={{ flexGrow: 0 }}>
<HorizontalScroll style={{ flexGrow: 0 }}>
<View
style={{
borderWidth: edge.hairline,
Expand Down Expand Up @@ -140,6 +141,6 @@ export function MarkdownTable({
/>
))}
</View>
</ScrollView>
</HorizontalScroll>
);
}
11 changes: 4 additions & 7 deletions apps/mobile/src/ui/markdown-text.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { decodeHTML } from 'entities';
import type { Token } from 'marked';
import type { ReactNode } from 'react';
import { Pressable, ScrollView, View } from 'react-native';
import { Pressable, View } from 'react-native';
import { HorizontalScroll } from './primitives/horizontal-scroll';

import { copyMessage } from './chat/message-menu';
import { edge, radius, space } from './design/tokens';
Expand Down Expand Up @@ -102,15 +103,11 @@ export function MarkdownText({
</Text>
</Pressable>
</View>
<ScrollView
horizontal
style={{ flexGrow: 0 }}
contentContainerStyle={{ padding: space.x3 }}
>
<HorizontalScroll style={{ flexGrow: 0 }} contentContainerStyle={{ padding: space.x3 }}>
<Text selectable={selectable} variant="meta" style={{ color, fontFamily: theme.mono }}>
{token.text}
</Text>
</ScrollView>
</HorizontalScroll>
</View>
);
if (token.type === 'list')
Expand Down
Loading
Loading