From 5b5c64837cf7784b74694f35fa999d2d2fff80ac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:08:37 +0000 Subject: [PATCH 01/19] feat(mobile): pull up past the newest message to start typing on iOS Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TYsJ1DZQ7Vd8sKVs6MMhdF --- apps/mobile/DESIGN.md | 1 + apps/mobile/src/ui/chat/composer.tsx | 7 +- .../src/ui/chat/conversation-screen.tsx | 42 +++++++- .../src/ui/chat/pull-to-compose-hint.tsx | 58 +++++++++++ apps/mobile/src/ui/chat/pull-to-compose.ts | 95 ++++++++++++++++++ apps/mobile/src/ui/design/tokens.ts | 2 + apps/mobile/src/ui/haptics.ts | 2 +- apps/mobile/src/ui/strings.ts | 4 + .../test/conversation-navigation.test.tsx | 2 + .../test/conversation-streaming-rows.test.tsx | 2 + apps/mobile/test/pull-to-compose.test.tsx | 97 +++++++++++++++++++ 11 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/ui/chat/pull-to-compose-hint.tsx create mode 100644 apps/mobile/src/ui/chat/pull-to-compose.ts create mode 100644 apps/mobile/test/pull-to-compose.test.tsx diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index b93ba42cdd..454da923af 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -167,6 +167,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - 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. - 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 with a ⌄ then floats at the trailing edge `space.x2` above the composer, aligned with its trailing edge, like Grok (a 36pt Liquid Glass circle like **+** on iOS 26 with Reduce Transparency off, otherwise `raised`), 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. + - **Pull up to type (iOS).** At the newest message, pulling the list further up past its end (the rubber-band bounce) and letting go focuses the composer and raises the keyboard, like pull-to-refresh in reverse. A drag counts only when it begins at the newest message (within the follow slop), so reading history never triggers it. A capsule with a keyboard symbol fades in above the composer as the pull grows, reading "Pull up to type" and switching to "Release to type" with one light haptic once the pull passes `pullToCompose.threshold` (64pt of offset); easing back below it before release cancels. It is off where there is no composer (a Bot-to-Bot DM) or the Host is not connected. Android has no offset past the end (its overscroll stretches instead), so it does not get this gesture. - 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. diff --git a/apps/mobile/src/ui/chat/composer.tsx b/apps/mobile/src/ui/chat/composer.tsx index f7ac2eb7a3..49b253e92d 100644 --- a/apps/mobile/src/ui/chat/composer.tsx +++ b/apps/mobile/src/ui/chat/composer.tsx @@ -1,5 +1,5 @@ import { TextInput } from '../primitives/system-text'; -import { useRef, useState, type ReactNode } from 'react'; +import { useRef, useState, type ComponentRef, type ReactNode, type Ref } from 'react'; import { Image, Keyboard, @@ -347,6 +347,8 @@ function AttachMenu({ onPick }: { onPick(source: PickSource): void }) { ); } +export type ComposerInput = ComponentRef; + export function Composer({ value, placeholder, @@ -366,6 +368,7 @@ export function Composer({ above, selection, onSelection, + inputRef, }: { value: string; placeholder: string; @@ -385,6 +388,7 @@ export function Composer({ above?: ReactNode; selection?: { start: number; end: number } | undefined; onSelection?(caret: number): void; + inputRef?: Ref; }) { const theme = useDesignTheme(); const insets = useSafeAreaInsets(); @@ -462,6 +466,7 @@ export function Composer({ const field = ( ; + export type ChatPlace = | { kind: 'dm'; slug: string | undefined } | { kind: 'channel'; channelId: string | undefined }; @@ -366,6 +372,18 @@ function ChatConversation({ [speakers, scroll, viewport, viewportLayout, composerHeight], ); const latest = useFollowLatest(list, visible, playback?.setFollowing, headerOverlap); + const composerInput = useRef(null); + const composable = + Platform.OS === 'ios' && + !botDm && + conversation !== undefined && + host.connection.status === 'connected'; + const compose = useCallback(() => composerInput.current?.focus(), []); + const pull = usePullToCompose({ + enabled: composable, + threshold: pullToCompose.threshold, + onCompose: compose, + }); const markdownViewport = useMemo( () => ({ scroll, viewport, content, layout: viewportLayout, active: focused }), [scroll, viewport, content, viewportLayout, focused], @@ -810,6 +828,7 @@ function ChatConversation({ }} onStopDictation={dictation.stop} onCancelDictation={dictation.cancel} + inputRef={composerInput} /> )} @@ -907,6 +926,18 @@ function ChatConversation({ {...(latest.ready ? { onStartReached: () => void conversation?.loadOlder() } : {})} onStartReachedThreshold={0.5} {...latest.listProps} + onScrollBeginDrag={(event: ScrollEvent) => { + pull.onScrollBeginDrag(event); + latest.listProps.onScrollBeginDrag(); + }} + onScroll={(event: ScrollEvent) => { + latest.listProps.onScroll(event); + pull.onScroll(event); + }} + onScrollEndDrag={(event: ScrollEvent) => { + latest.listProps.onScrollEndDrag(event); + pull.onScrollEndDrag(event); + }} onContentSizeChange={(width: number, height: number) => { latest.listProps.onContentSizeChange(width, height); jump(); @@ -952,6 +983,13 @@ function ChatConversation({ {nativeScrollEdge ? null : ( )} + {composable ? ( + + ) : null} {latest.away ? ( ; + ready: boolean; + bottom: number; +}) { + const theme = useDesignTheme(); + const layer = theme.layer.raised; + const style = useAnimatedStyle(() => ({ + opacity: interpolate(progress.value, [0.25, 1], [0, 1], 'clamp'), + transform: [{ scale: interpolate(progress.value, [0.25, 1], [0.9, 1], 'clamp') }], + })); + return ( + + + + + {ready ? t.releaseToCompose : t.pullToCompose} + + + + ); +} diff --git a/apps/mobile/src/ui/chat/pull-to-compose.ts b/apps/mobile/src/ui/chat/pull-to-compose.ts new file mode 100644 index 0000000000..458cf260cb --- /dev/null +++ b/apps/mobile/src/ui/chat/pull-to-compose.ts @@ -0,0 +1,95 @@ +import { useCallback, useRef, useState } from 'react'; +import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; +import { + ReduceMotion, + useSharedValue, + withTiming, + type SharedValue, +} from 'react-native-reanimated'; + +import { motion } from '../design/tokens'; +import { haptic } from '../haptics'; +import { atLatest, distanceFromLatest } from './follow-latest'; + +type ScrollEvent = NativeSyntheticEvent; + +export function pulledPast(offset: number, from: number): number { + return Math.max(0, offset - from); +} + +function measure(event: ScrollEvent) { + const native = event.nativeEvent; + return { + offset: native.contentOffset.y, + distance: distanceFromLatest({ + content: native.contentSize?.height ?? 0, + layout: native.layoutMeasurement?.height ?? 0, + offset: native.contentOffset.y, + }), + }; +} + +export type PullToCompose = { + progress: SharedValue; + ready: boolean; + onScrollBeginDrag(event: ScrollEvent): void; + onScroll(event: ScrollEvent): void; + onScrollEndDrag(event: ScrollEvent): void; +}; + +export function usePullToCompose({ + enabled, + threshold, + onCompose, +}: { + enabled: boolean; + threshold: number; + onCompose(): void; +}): PullToCompose { + const from = useRef(undefined); + const past = useRef(false); + const [ready, setReady] = useState(false); + const progress = useSharedValue(0); + + const reach = useCallback((next: boolean) => { + if (next === past.current) return; + past.current = next; + setReady(next); + if (next) haptic('pull'); + }, []); + + const onScrollBeginDrag = useCallback( + (event: ScrollEvent) => { + const { offset, distance } = measure(event); + from.current = enabled && atLatest(distance) ? offset : undefined; + }, + [enabled], + ); + + const onScroll = useCallback( + (event: ScrollEvent) => { + if (from.current === undefined) return; + const pulled = pulledPast(event.nativeEvent.contentOffset.y, from.current); + progress.value = Math.min(1, pulled / threshold); + reach(pulled >= threshold); + }, + [progress, threshold, reach], + ); + + const onScrollEndDrag = useCallback( + (event: ScrollEvent) => { + if (from.current === undefined) return; + const pulled = pulledPast(event.nativeEvent.contentOffset.y, from.current); + from.current = undefined; + progress.value = withTiming(0, { + duration: motion.composer, + reduceMotion: ReduceMotion.System, + }); + reach(false); + if (enabled && pulled >= threshold) onCompose(); + }, + [enabled, threshold, onCompose, progress, reach], + ); + + return { progress, ready, onScrollBeginDrag, onScroll, onScrollEndDrag }; +} diff --git a/apps/mobile/src/ui/design/tokens.ts b/apps/mobile/src/ui/design/tokens.ts index 4b34c7b1fe..44ecbca5ec 100644 --- a/apps/mobile/src/ui/design/tokens.ts +++ b/apps/mobile/src/ui/design/tokens.ts @@ -122,6 +122,8 @@ export const motion = { menu: 160, } as const; +export const pullToCompose = { threshold: 64 } as const; + export const markdownTable = { minColumnWidth: 96, maxColumnWidth: 280 } as const; export const drawerSpring = { duration: 280, dampingRatio: 1 } as const; diff --git a/apps/mobile/src/ui/haptics.ts b/apps/mobile/src/ui/haptics.ts index 597f40c0a3..4655bb8c0c 100644 --- a/apps/mobile/src/ui/haptics.ts +++ b/apps/mobile/src/ui/haptics.ts @@ -1,6 +1,6 @@ import * as Haptics from 'expo-haptics'; -export type HapticMoment = 'send' | 'approve' | 'deny' | 'submit' | 'menu'; +export type HapticMoment = 'send' | 'approve' | 'deny' | 'submit' | 'menu' | 'pull'; export function haptic(moment: HapticMoment): void { const done = diff --git a/apps/mobile/src/ui/strings.ts b/apps/mobile/src/ui/strings.ts index 2ba9693668..746ee6d763 100644 --- a/apps/mobile/src/ui/strings.ts +++ b/apps/mobile/src/ui/strings.ts @@ -520,6 +520,8 @@ const en = { typing: 'Replying…', olderLoading: 'Loading earlier messages…', jumpToLatest: 'Return to latest messages', + pullToCompose: 'Pull up to type', + releaseToCompose: 'Release to type', unseenLatest: (count: number) => `${count} new ${count === 1 ? 'message' : 'messages'} · Jump to latest`, beginning: 'This is the beginning of your DM.', @@ -1514,6 +1516,8 @@ const zh: typeof en = { typing: '正在回复…', olderLoading: '正在加载更早的消息…', jumpToLatest: '返回最新消息', + pullToCompose: '上拉输入消息', + releaseToCompose: '松手输入消息', unseenLatest: (count: number) => `${count} 条新消息 · 跳到最新`, beginning: '这是你们私信的开始。', beginningChannel: '这是这个频道的开始。', diff --git a/apps/mobile/test/conversation-navigation.test.tsx b/apps/mobile/test/conversation-navigation.test.tsx index 061f60e8c0..927c51e8f5 100644 --- a/apps/mobile/test/conversation-navigation.test.tsx +++ b/apps/mobile/test/conversation-navigation.test.tsx @@ -144,6 +144,8 @@ 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/pull-to-compose-hint', () => ({ PullToComposeHint: () => null })); +vi.mock('expo-haptics', () => import('./expo-haptics-shim')); vi.mock('../src/ui/chat/message-actions', () => ({ MessageActions: () => null })); vi.mock('../src/ui/chat/message-bubble', () => ({ PlaybackBubble: ({ message }: { message?: { body: string } }) => diff --git a/apps/mobile/test/conversation-streaming-rows.test.tsx b/apps/mobile/test/conversation-streaming-rows.test.tsx index 0d1986a501..3f9f020780 100644 --- a/apps/mobile/test/conversation-streaming-rows.test.tsx +++ b/apps/mobile/test/conversation-streaming-rows.test.tsx @@ -178,6 +178,8 @@ 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/pull-to-compose-hint', () => ({ PullToComposeHint: () => null })); +vi.mock('expo-haptics', () => import('./expo-haptics-shim')); vi.mock('../src/ui/chat/message-actions', () => ({ MessageActions: () => null })); vi.mock('../src/ui/chat/message-bubble', async () => { const { memo, useCallback, useSyncExternalStore } = await import('react'); diff --git a/apps/mobile/test/pull-to-compose.test.tsx b/apps/mobile/test/pull-to-compose.test.tsx new file mode 100644 index 0000000000..afe17e6c8a --- /dev/null +++ b/apps/mobile/test/pull-to-compose.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('react-native-reanimated', () => import('./reanimated-shim')); +vi.mock('expo-haptics', () => import('./expo-haptics-shim')); + +const { played } = await import('./expo-haptics-shim'); +const { pulledPast, usePullToCompose } = await import('../src/ui/chat/pull-to-compose'); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const CONTENT = 2000; +const LAYOUT = 800; +const END = CONTENT - LAYOUT; +const THRESHOLD = 64; + +const at = (offset: number) => + ({ + nativeEvent: { + contentOffset: { y: offset }, + contentSize: { height: CONTENT }, + layoutMeasurement: { height: LAYOUT }, + }, + }) as NativeSyntheticEvent; + +function mount(enabled = true) { + const onCompose = vi.fn(); + const probe: { current?: ReturnType } = {}; + function Probe() { + probe.current = usePullToCompose({ enabled, threshold: THRESHOLD, onCompose }); + return null; + } + act(() => createRoot(document.createElement('div')).render()); + const pull = () => probe.current!; + const drag = (from: number, to: number) => + act(() => { + pull().onScrollBeginDrag(at(from)); + pull().onScroll(at(to)); + pull().onScrollEndDrag(at(to)); + }); + return { onCompose, pull, drag }; +} + +beforeEach(() => { + played.length = 0; +}); + +describe('pull to compose', () => { + it('measures only the pull past where the drag began', () => { + expect(pulledPast(END + 30, END)).toBe(30); + expect(pulledPast(END - 30, END)).toBe(0); + }); + + it('opens the composer when a drag from the latest message pulls past the threshold', () => { + const { onCompose, drag } = mount(); + drag(END, END + THRESHOLD); + expect(onCompose).toHaveBeenCalledTimes(1); + expect(played).toEqual(['impact:light']); + }); + + it('does nothing for a short pull, so the bounce alone stays harmless', () => { + const { onCompose, drag } = mount(); + drag(END, END + THRESHOLD - 1); + expect(onCompose).not.toHaveBeenCalled(); + expect(played).toEqual([]); + }); + + it('lets the user cancel by easing back before release', () => { + const { onCompose, pull } = mount(); + act(() => { + pull().onScrollBeginDrag(at(END)); + pull().onScroll(at(END + THRESHOLD + 10)); + }); + expect(pull().ready).toBe(true); + act(() => { + pull().onScroll(at(END + 10)); + pull().onScrollEndDrag(at(END + 10)); + }); + expect(pull().ready).toBe(false); + expect(onCompose).not.toHaveBeenCalled(); + }); + + it('ignores drags that start while reading history', () => { + const { onCompose, drag } = mount(); + drag(END - 400, END - 400 + THRESHOLD * 2); + expect(onCompose).not.toHaveBeenCalled(); + }); + + it('stays off where there is no composer to open', () => { + const { onCompose, drag } = mount(false); + drag(END, END + THRESHOLD * 2); + expect(onCompose).not.toHaveBeenCalled(); + }); +}); From 6d2bb846b9f7e8e8a74cf4acc911879292289c7c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:09:05 +0000 Subject: [PATCH 02/19] docs(changes): pull to compose entry Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TYsJ1DZQ7Vd8sKVs6MMhdF --- changes/2364-pull-to-compose.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 changes/2364-pull-to-compose.md diff --git a/changes/2364-pull-to-compose.md b/changes/2364-pull-to-compose.md new file mode 100644 index 0000000000..28dfc3a6bd --- /dev/null +++ b/changes/2364-pull-to-compose.md @@ -0,0 +1,11 @@ +--- +section: Added +--- + +## en + +- On iOS, pulling up past the newest message in a chat and letting go now opens the keyboard to start typing, with a hint and a light haptic once the pull is far enough ([#2364](https://github.com/BotHarness/DeepSeekBot/pull/2364)). + +## zh + +- iOS 聊天页滚到最新消息后继续上拉并松手,会直接拉起输入框和键盘开始输入;拉到位时出现提示并有一次轻触反馈([#2364](https://github.com/BotHarness/DeepSeekBot/pull/2364))。 From 2ef661573df85f7c572d7ff9984c14197b4af3c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:16:36 +0000 Subject: [PATCH 03/19] feat(mobile): offer a clipboard image above the composer on iOS Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TYsJ1DZQ7Vd8sKVs6MMhdF --- apps/mobile/src/host/pick-files.ts | 12 +++ apps/mobile/src/ui/chat/clipboard-image.tsx | 87 ++++++++++++++++++ apps/mobile/src/ui/chat/composer.tsx | 8 +- .../src/ui/chat/conversation-screen.tsx | 32 ++++++- apps/mobile/src/ui/strings.ts | 2 + apps/mobile/test/clipboard-image.test.tsx | 91 +++++++++++++++++++ .../test/conversation-navigation.test.tsx | 4 + .../test/conversation-streaming-rows.test.tsx | 4 + 8 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/src/ui/chat/clipboard-image.tsx create mode 100644 apps/mobile/test/clipboard-image.test.tsx diff --git a/apps/mobile/src/host/pick-files.ts b/apps/mobile/src/host/pick-files.ts index e62ae789a5..e0b0ff9e03 100644 --- a/apps/mobile/src/host/pick-files.ts +++ b/apps/mobile/src/host/pick-files.ts @@ -47,6 +47,18 @@ function fromDocument(asset: DocumentPicker.DocumentPickerAsset): PickedFile { }; } +export function fromClipboardImage(data: string): PickedFile { + picked += 1; + const mime = /^data:([^;,]+)/u.exec(data)?.[1]; + return { + id: `pick-${picked}`, + kind: 'image', + uri: data, + name: `pasted-${picked}${extension(mime)}`, + read: readUri(data), + }; +} + export async function pickFiles(source: PickSource, limit: number): Promise { if (limit <= 0) return []; if (source === 'file') { diff --git a/apps/mobile/src/ui/chat/clipboard-image.tsx b/apps/mobile/src/ui/chat/clipboard-image.tsx new file mode 100644 index 0000000000..2105ed3b13 --- /dev/null +++ b/apps/mobile/src/ui/chat/clipboard-image.tsx @@ -0,0 +1,87 @@ +import * as Clipboard from 'expo-clipboard'; +import { useState, useSyncExternalStore } from 'react'; +import { Platform, View } from 'react-native'; + +import { useDesignTheme } from '../design/theme'; +import { space } from '../design/tokens'; +import { Text } from '../primitives/text'; +import { t } from '../strings'; + +type ClipboardState = { image: boolean; generation: number }; + +let state: ClipboardState = { image: false, generation: 0 }; +const readClipboard = () => state; + +function subscribeClipboard(notify: () => void) { + let active = true; + let changed = false; + const subscription = Clipboard.addClipboardListener((event) => { + changed = true; + state = { + image: event.contentTypes.includes(Clipboard.ContentType.IMAGE), + generation: state.generation + 1, + }; + notify(); + }); + void Clipboard.hasImageAsync().then( + (image) => { + if (!active || changed || image === state.image) return; + state = { ...state, image }; + notify(); + }, + () => {}, + ); + return () => { + active = false; + subscription.remove(); + }; +} + +const unsubscribed = () => () => {}; + +export const clipboardPasteAvailable = Platform.OS === 'ios' && Clipboard.isPasteButtonAvailable; + +export function useClipboardImage(watching: boolean) { + const current = useSyncExternalStore( + watching && clipboardPasteAvailable ? subscribeClipboard : unsubscribed, + readClipboard, + ); + const [used, setUsed] = useState(-1); + return { + offered: watching && clipboardPasteAvailable && current.image && used !== current.generation, + used: () => setUsed(current.generation), + }; +} + +export function ClipboardImageOffer({ onPaste }: { onPaste(data: string): void }) { + const theme = useDesignTheme(); + return ( + + + {t.clipboardImage} + + { + if (data.type === 'image') onPaste(data.data); + }} + /> + + ); +} diff --git a/apps/mobile/src/ui/chat/composer.tsx b/apps/mobile/src/ui/chat/composer.tsx index 49b253e92d..a06c839826 100644 --- a/apps/mobile/src/ui/chat/composer.tsx +++ b/apps/mobile/src/ui/chat/composer.tsx @@ -369,6 +369,7 @@ export function Composer({ selection, onSelection, inputRef, + onFocusChange, }: { value: string; placeholder: string; @@ -389,6 +390,7 @@ export function Composer({ selection?: { start: number; end: number } | undefined; onSelection?(caret: number): void; inputRef?: Ref; + onFocusChange?(focused: boolean): void; }) { const theme = useDesignTheme(); const insets = useSafeAreaInsets(); @@ -477,7 +479,11 @@ export function Composer({ onSelectionChange: (event: { nativeEvent: { selection: { end: number } } }) => onSelection(event.nativeEvent.selection.end), })} - onFocus={() => setMenu(false)} + onFocus={() => { + setMenu(false); + onFocusChange?.(true); + }} + onBlur={() => onFocusChange?.(false)} placeholder={ held ? t.dictationHoldHint diff --git a/apps/mobile/src/ui/chat/conversation-screen.tsx b/apps/mobile/src/ui/chat/conversation-screen.tsx index 97b4a17645..f5091e354a 100644 --- a/apps/mobile/src/ui/chat/conversation-screen.tsx +++ b/apps/mobile/src/ui/chat/conversation-screen.tsx @@ -36,7 +36,13 @@ import { import type { Inbox } from '../../host/inbox'; import type { WireAllBotPreview } from '../../host/wire'; import { shareAttachment } from '../../host/share-attachment'; -import { MAX_PICKED, pickFiles, type PickSource, type PickedFile } from '../../host/pick-files'; +import { + MAX_PICKED, + fromClipboardImage, + pickFiles, + type PickSource, + type PickedFile, +} from '../../host/pick-files'; import { useChannelConversation, useConversation, @@ -62,6 +68,7 @@ import { botBusy } from './bot-tone'; import { ChannelTitle } from './channel-title'; import { ChatTitle } from './chat-title'; import { Composer, type ComposerInput } from './composer'; +import { ClipboardImageOffer, useClipboardImage } from './clipboard-image'; import { EdgeFade, HeaderBackButton } from './header-chrome'; import { ScrollEdgeElement, nativeScrollEdge } from '../primitives/scroll-edge-element'; import { barLabelStyle, glassHeader, useHeaderOverlap } from './native-header'; @@ -373,6 +380,8 @@ function ChatConversation({ ); const latest = useFollowLatest(list, visible, playback?.setFollowing, headerOverlap); const composerInput = useRef(null); + const [typing, setTyping] = useState(false); + const clipboard = useClipboardImage(typing && files.length < MAX_PICKED); const composable = Platform.OS === 'ios' && !botDm && @@ -816,9 +825,23 @@ function ChatConversation({ setSelection(undefined); setCaret(next); }} - {...(candidates.length === 0 - ? {} - : { above: })} + {...(candidates.length > 0 + ? { above: } + : clipboard.offered + ? { + above: ( + { + clipboard.used(); + setAttachError(false); + setFiles((current) => + [...current, fromClipboardImage(data)].slice(0, MAX_PICKED), + ); + }} + /> + ), + } + : {})} onSend={send} onPick={pick} onRemove={(id) => setFiles((current) => current.filter((file) => file.id !== id))} @@ -829,6 +852,7 @@ function ChatConversation({ onStopDictation={dictation.stop} onCancelDictation={dictation.cancel} inputRef={composerInput} + onFocusChange={setTyping} /> )} diff --git a/apps/mobile/src/ui/strings.ts b/apps/mobile/src/ui/strings.ts index 746ee6d763..876105f63d 100644 --- a/apps/mobile/src/ui/strings.ts +++ b/apps/mobile/src/ui/strings.ts @@ -521,6 +521,7 @@ const en = { olderLoading: 'Loading earlier messages…', jumpToLatest: 'Return to latest messages', pullToCompose: 'Pull up to type', + clipboardImage: 'Image on the clipboard', releaseToCompose: 'Release to type', unseenLatest: (count: number) => `${count} new ${count === 1 ? 'message' : 'messages'} · Jump to latest`, @@ -1517,6 +1518,7 @@ const zh: typeof en = { olderLoading: '正在加载更早的消息…', jumpToLatest: '返回最新消息', pullToCompose: '上拉输入消息', + clipboardImage: '剪贴板里有一张图片', releaseToCompose: '松手输入消息', unseenLatest: (count: number) => `${count} 条新消息 · 跳到最新`, beginning: '这是你们私信的开始。', diff --git a/apps/mobile/test/clipboard-image.test.tsx b/apps/mobile/test/clipboard-image.test.tsx new file mode 100644 index 0000000000..8bf5608054 --- /dev/null +++ b/apps/mobile/test/clipboard-image.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const listeners: Array<(event: { contentTypes: string[] }) => void> = []; +let hasImage = false; + +vi.mock('react-native', () => ({ Platform: { OS: 'ios' }, View: () => null })); +vi.mock('expo-image-picker', () => ({})); +vi.mock('expo-document-picker', () => ({})); +vi.mock('expo-clipboard', () => ({ + ContentType: { IMAGE: 'image', PLAIN_TEXT: 'plain-text' }, + isPasteButtonAvailable: true, + hasImageAsync: async () => hasImage, + addClipboardListener: (listener: (event: { contentTypes: string[] }) => void) => { + listeners.push(listener); + return { remove: () => listeners.splice(listeners.indexOf(listener), 1) }; + }, + ClipboardPasteButton: () => null, +})); +vi.mock('../src/ui/design/theme', () => ({ useDesignTheme: () => ({}) })); +vi.mock('../src/ui/primitives/text', () => ({ Text: () => null })); + +const { useClipboardImage } = await import('../src/ui/chat/clipboard-image'); +const { fromClipboardImage } = await import('../src/host/pick-files'); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function mount(watching: boolean) { + const probe: { current?: ReturnType } = {}; + function Probe({ on }: { on: boolean }) { + probe.current = useClipboardImage(on); + return null; + } + const root = createRoot(document.createElement('div')); + act(() => root.render()); + return { + get current() { + return probe.current!; + }, + rerender: (on: boolean) => act(() => root.render()), + unmount: () => act(() => root.unmount()), + }; +} + +const copy = (contentTypes: string[]) => + act(() => { + for (const listener of [...listeners]) listener({ contentTypes }); + }); + +beforeEach(() => { + hasImage = false; +}); + +describe('clipboard image offer', () => { + it('offers an image already on the clipboard once the field is focused', async () => { + hasImage = true; + const view = mount(true); + await act(async () => {}); + expect(view.current.offered).toBe(true); + view.unmount(); + }); + + it('does not watch the clipboard while the field is not focused', () => { + const view = mount(false); + expect(listeners).toHaveLength(0); + expect(view.current.offered).toBe(false); + view.unmount(); + }); + + it('hides after pasting until something new is copied', async () => { + const view = mount(true); + await act(async () => {}); + copy(['image']); + expect(view.current.offered).toBe(true); + act(() => view.current.used()); + expect(view.current.offered).toBe(false); + copy(['image']); + expect(view.current.offered).toBe(true); + copy(['plain-text']); + expect(view.current.offered).toBe(false); + view.unmount(); + }); + + it('attaches the pasted image as a picked image with a matching name', () => { + const file = fromClipboardImage('data:image/jpeg;base64,AAAA'); + expect(file).toMatchObject({ kind: 'image', uri: 'data:image/jpeg;base64,AAAA' }); + expect(file.name).toMatch(/^pasted-\d+\.jpg$/u); + }); +}); diff --git a/apps/mobile/test/conversation-navigation.test.tsx b/apps/mobile/test/conversation-navigation.test.tsx index 927c51e8f5..724cccb756 100644 --- a/apps/mobile/test/conversation-navigation.test.tsx +++ b/apps/mobile/test/conversation-navigation.test.tsx @@ -145,6 +145,10 @@ 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/pull-to-compose-hint', () => ({ PullToComposeHint: () => null })); +vi.mock('../src/ui/chat/clipboard-image', () => ({ + ClipboardImageOffer: () => null, + useClipboardImage: () => ({ offered: false, used: () => {} }), +})); vi.mock('expo-haptics', () => import('./expo-haptics-shim')); vi.mock('../src/ui/chat/message-actions', () => ({ MessageActions: () => null })); vi.mock('../src/ui/chat/message-bubble', () => ({ diff --git a/apps/mobile/test/conversation-streaming-rows.test.tsx b/apps/mobile/test/conversation-streaming-rows.test.tsx index 3f9f020780..b861b976e0 100644 --- a/apps/mobile/test/conversation-streaming-rows.test.tsx +++ b/apps/mobile/test/conversation-streaming-rows.test.tsx @@ -179,6 +179,10 @@ 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/pull-to-compose-hint', () => ({ PullToComposeHint: () => null })); +vi.mock('../src/ui/chat/clipboard-image', () => ({ + ClipboardImageOffer: () => null, + useClipboardImage: () => ({ offered: false, used: () => {} }), +})); vi.mock('expo-haptics', () => import('./expo-haptics-shim')); vi.mock('../src/ui/chat/message-actions', () => ({ MessageActions: () => null })); vi.mock('../src/ui/chat/message-bubble', async () => { From acaf7b49fcf978459720861c4eb5d0b022219de1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:17:43 +0000 Subject: [PATCH 04/19] feat(mobile): double-tap a message to add or take back a thumbs up on iOS Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TYsJ1DZQ7Vd8sKVs6MMhdF --- apps/mobile/src/ui/chat/message-bubble.tsx | 37 +++++++++++++++- apps/mobile/src/ui/design/tokens.ts | 2 + apps/mobile/src/ui/haptics.ts | 2 +- apps/mobile/test/message-bubble.test.tsx | 49 ++++++++++++++++++++-- 4 files changed, 84 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/ui/chat/message-bubble.tsx b/apps/mobile/src/ui/chat/message-bubble.tsx index 4d89745801..6c2e992de9 100644 --- a/apps/mobile/src/ui/chat/message-bubble.tsx +++ b/apps/mobile/src/ui/chat/message-bubble.tsx @@ -25,12 +25,13 @@ import Animated, { import { reactable, + reactedByMe, type ConversationDraft, type ConversationMessage, type MessageAttachment, } from '../../host/conversation'; import type { ConversationPlayback } from '../../host/conversation-playback'; -import { lift, motion, radius, space } from '../design/tokens'; +import { doubleTap, lift, motion, radius, space } from '../design/tokens'; import { pressTint, useDesignTheme } from '../design/theme'; import { MessageBody } from './message-body'; import { Text } from '../primitives/text'; @@ -109,6 +110,7 @@ function PressableBubble({ accessibilityActionLabel, onPress, onLongPress, + onDoubleTap, children, }: { human: boolean; @@ -120,12 +122,14 @@ function PressableBubble({ accessibilityActionLabel?: string; onPress?: () => void; onLongPress?: (anchor: Rect) => void; + onDoubleTap?: () => void; children: ReactNode; }) { const theme = useDesignTheme(); const reduced = useReducedMotion(); const ref = useRef(null); const anchor = useRef(undefined); + const tapped = useRef(undefined); const scale = useSharedValue(1); const held = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] })); const shrinks = Platform.OS === 'ios' && !reduced && onLongPress !== undefined; @@ -174,7 +178,20 @@ function PressableBubble({ open(); }, })} - {...(onPress === undefined ? {} : { onPress })} + {...(onPress !== undefined + ? { onPress } + : onDoubleTap === undefined + ? {} + : { + onPress: () => { + const now = Date.now(); + if (secondTap(tapped.current, now)) { + tapped.current = undefined; + haptic('react'); + onDoubleTap(); + } else tapped.current = now; + }, + })} delayLongPress={motion.lift} android_ripple={{ color: pressTint(human ? theme.color.onAccent : theme.color.fg), @@ -199,6 +216,10 @@ function PressableBubble({ ); } +export function secondTap(last: number | undefined, now: number): boolean { + return last !== undefined && now - last <= doubleTap.window; +} + export const MessageBubble = memo(function MessageBubble({ message, first = true, @@ -270,6 +291,18 @@ export const MessageBubble = memo(function MessageBubble({ } : {})} {...(failed ? { onPress: () => onRetry?.(message.id) } : {})} + {...(!streaming && onReact !== undefined && reactable(message) && Platform.OS === 'ios' + ? { + onDoubleTap: () => + onReact( + message.id, + doubleTap.emoji, + !(message.reactions ?? []).some( + (reaction) => reaction.emoji === doubleTap.emoji && reactedByMe(reaction), + ), + ), + } + : {})} > {bubble} diff --git a/apps/mobile/src/ui/design/tokens.ts b/apps/mobile/src/ui/design/tokens.ts index 44ecbca5ec..ddea503a5c 100644 --- a/apps/mobile/src/ui/design/tokens.ts +++ b/apps/mobile/src/ui/design/tokens.ts @@ -124,6 +124,8 @@ export const motion = { export const pullToCompose = { threshold: 64 } as const; +export const doubleTap = { window: 300, emoji: '👍' } as const; + export const markdownTable = { minColumnWidth: 96, maxColumnWidth: 280 } as const; export const drawerSpring = { duration: 280, dampingRatio: 1 } as const; diff --git a/apps/mobile/src/ui/haptics.ts b/apps/mobile/src/ui/haptics.ts index 4655bb8c0c..125896ccd8 100644 --- a/apps/mobile/src/ui/haptics.ts +++ b/apps/mobile/src/ui/haptics.ts @@ -1,6 +1,6 @@ import * as Haptics from 'expo-haptics'; -export type HapticMoment = 'send' | 'approve' | 'deny' | 'submit' | 'menu' | 'pull'; +export type HapticMoment = 'send' | 'approve' | 'deny' | 'submit' | 'menu' | 'pull' | 'react'; export function haptic(moment: HapticMoment): void { const done = diff --git a/apps/mobile/test/message-bubble.test.tsx b/apps/mobile/test/message-bubble.test.tsx index 4fb2534698..3db38e4cde 100644 --- a/apps/mobile/test/message-bubble.test.tsx +++ b/apps/mobile/test/message-bubble.test.tsx @@ -20,8 +20,9 @@ vi.mock('react-native', async () => { const { useWindowDimensions, StyleSheet } = await import('./react-native-shim'); const host = (name: string) => { const Component = ({ children, ...props }: Record) => { - if (typeof props['onPress'] === 'function' && typeof props['accessibilityLabel'] === 'string') - native.presses.set(props['accessibilityLabel'], props['onPress'] as () => void); + const pressKey = props['accessibilityLabel'] ?? props['accessibilityHint']; + if (typeof props['onPress'] === 'function' && typeof pressKey === 'string') + native.presses.set(pressKey, props['onPress'] as () => void); const attributes: Record = {}; for (const key of [ 'accessibilityLabel', @@ -124,7 +125,8 @@ vi.mock('react-native-svg', () => ({ Rect: () => null, })); -const { MessageBubble, DraftBubble, deliveryLabel } = await import('../src/ui/chat/message-bubble'); +const { MessageBubble, DraftBubble, deliveryLabel, secondTap } = + await import('../src/ui/chat/message-bubble'); const { createSpeakerRegistry } = await import('../src/ui/chat/speaker-row'); const { t } = await import('../src/ui/strings'); const { openMessageMenu, shareText } = await import('../src/ui/chat/message-menu'); @@ -381,6 +383,47 @@ describe('copying a message', () => { }); }); +describe('double-tap to react', () => { + const bot: ConversationMessage = { + id: 'bot-1', + revision: 3, + at: '2026-10-14T08:00:00.000Z', + author: 'bot', + body: 'Done.', + attachments: [], + delivery: 'committed', + }; + const doubleTap = (message: ConversationMessage) => { + const onReact = vi.fn(); + renderToStaticMarkup(); + const press = native.presses.get(t.messageActionsHint); + press?.(); + press?.(); + return onReact; + }; + + it('counts a second tap only inside the double-tap window', () => { + expect(secondTap(undefined, 1000)).toBe(false); + expect(secondTap(1000, 1300)).toBe(true); + expect(secondTap(1000, 1301)).toBe(false); + }); + + it('adds a thumbs up on iOS', () => { + expect(doubleTap(bot)).toHaveBeenCalledWith('bot-1', '👍', true); + }); + + it('takes the thumbs up back when it is already mine', () => { + const mine = { emoji: '👍', actors: [{ kind: 'human' as const, id: 'local-human' }] }; + expect(doubleTap({ ...bot, reactions: [mine] })).toHaveBeenCalledWith('bot-1', '👍', false); + }); + + it('leaves Android and messages that cannot take reactions alone', () => { + expect(doubleTap({ ...bot, revision: undefined })).not.toHaveBeenCalled(); + native.os = 'android'; + expect(doubleTap(bot)).not.toHaveBeenCalled(); + }); +}); + describe('the long-press menu', () => { const anchor = { x: 16, y: 400, width: 200, height: 60 }; const group = { first: true, last: true }; From 59d3607f51216beba6345e4dffb2e2bd0149e5d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:20:07 +0000 Subject: [PATCH 05/19] feat(mobile): peek at a chat with a long-press on iOS; document the chat QoL gestures Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TYsJ1DZQ7Vd8sKVs6MMhdF --- apps/mobile/DESIGN.md | 3 +++ apps/mobile/app/(tabs)/(home)/index.tsx | 19 +++++++++----- .../mobile/src/ui/shell/conversation-peek.tsx | 14 ++++++++++ apps/mobile/src/ui/shell/open-conversation.ts | 12 ++++++--- apps/mobile/test/message-bubble.test.tsx | 2 +- apps/mobile/test/open-conversation.test.ts | 26 +++++++++++++++++++ changes/2364-ios-chat-qol.md | 13 ++++++++++ changes/2364-pull-to-compose.md | 11 -------- 8 files changed, 78 insertions(+), 22 deletions(-) create mode 100644 apps/mobile/src/ui/shell/conversation-peek.tsx create mode 100644 apps/mobile/test/open-conversation.test.ts create mode 100644 changes/2364-ios-chat-qol.md delete mode 100644 changes/2364-pull-to-compose.md diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index 454da923af..f0fc02e011 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -148,6 +148,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - A collapsed Channel section shows one count chip for everything inside it: Needs you first, otherwise the unread sum, leaving muted conversations out. An expanded section that holds Bots waiting for the Human says how many, in `warning`. - Opening a conversation marks it read on the Host (`channelMarkRead`) whenever the Human is at the newest message, so the chip clears on the phone and on desktop. - Swipe for pin and mute; long-press opens a native menu. +- On iOS a long-press on a Host conversation row peeks at the chat, like Messages: the row lifts into the system context-menu preview (expo-router `Link.Preview`) showing the conversation itself, and tapping the preview opens it. A plain tap opens the conversation as before. **Count chip** @@ -188,6 +189,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - Then the actions in groups, each row with a leading system icon (`RowIcon` symbols: an SF Symbol on iOS, a Material Symbol on Android) and its label: **Copy** and **Share** (the system share sheet) in one group; when an activity has a destination, **Copy** sits on its own and **Open …** joins **Share** in the second group. On iOS each group is a `raised` card at radius `lg` with hairline dividers; on Android the groups are `ListItem`s separated by dividers. - Every size follows the system text size: rows have a touch-height minimum and grow with their padded label, icons take their size from the text style, and each reaction circle is at least touch-sized and grows with its emoji. The iOS content reports its ideal height so the fitted sheet never compresses it. - A swipe down, a tap on the scrim, Android Back or any action closes the sheet. On iOS, Share and Open wait until the sheet has fully dismissed, so the next presentation does not collide with it. + - On iOS, double-tapping a bubble that can take reactions adds 👍 (or takes yours back), like Messages' tapback, with a light haptic. The first tap does nothing on its own, so a single tap stays free; taps more than 300ms apart (`doubleTap.window`) do not count. - A message that is still sending, failed, or is a card has no reactions, and neither does a read-only Bot DM or an activity row. Bubble text is not selectable, so the long-press always reaches this sheet; screen readers reach it through the bubble's "Message actions" action. - Delivery: pending shows at 70% opacity, failed offers "Not sent · Tap to retry", and sent shows nothing. - **Action cards** sit in the chat stream, and the same rules apply wherever they appear (DM, Activity, Human Inbox, web Inbox). Show only what the Human needs to decide; everything else waits behind Details. @@ -212,6 +214,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - Android follows Material 3 (Google Messages): **+** is a standard icon button with no container and a borderless ripple, and the field is a filled capsule on the `raised` fill with no edge and no glass. The mic, Send and expand buttons ripple too. - **+** sits outside the capsule, bottom-aligned as the field grows. While dictating it becomes **×** (cancel), and while the mic is held, a red dot. With one trailing button (the mic or Send) the capsule is one row: the field and the button. When the text no longer fits that one row, or with more (attached files, or the mic next to Send), the capsule becomes a rounded rectangle with the same corner radius and stacks like the web composer: the field on top at full width, and a row below with expand on the left and the buttons on the right. - **+** opens Photos, Camera and File below the composer. Picked files show as removable tiles inside the capsule, up to 10 per message. Send appears once there is text or a file. + - On iOS, while the field is focused and the clipboard holds an image, a row above the composer reads "Image on the clipboard" with the system Paste button (`UIPasteControl`, through `expo-clipboard`'s `ClipboardPasteButton`, image only, icon only at accessibility text sizes). Pressing it attaches the image as a JPEG tile, without the system paste-permission prompt, and the row hides until something new is copied. The @ suggestions take that slot first. A text field cannot take an image paste on its own in React Native, so this is the paste path. - Sending uploads each file to the Host first. A retry keeps the uploads that already finished. - Nothing sits under the composer, so the chat stays clean. A DM's Model and Workspace settings live in the Bot profile, in a **Bot settings** group under the profile actions: a **Model** row (the Bot's current model name, or "Loading…", "No model set" or "Unavailable") and a **Workspace** row (the folder count, "N folders"), each with a chevron. - **Model** opens the native Models and presets page inside the profile drawer: Orchestrator, Assignment default, allowed Assignment models, reasoning effort, restore global default, save as preset and apply a shared preset. The searchable chooser pushes inside that same drawer and returns only after an accepted save and canonical refresh. Editing only the Orchestrator preserves Assignment policy. Assignment edits retain other allowed models, efforts and notes; the current default cannot be removed until another is chosen. Existing preset copies and per-Bot plans retain the Host's revision checks. Global default updates use the Host's existing profile-default contract; mobile does not create a separate global Assignment authority. diff --git a/apps/mobile/app/(tabs)/(home)/index.tsx b/apps/mobile/app/(tabs)/(home)/index.tsx index f26015d080..bff7c96b17 100644 --- a/apps/mobile/app/(tabs)/(home)/index.tsx +++ b/apps/mobile/app/(tabs)/(home)/index.tsx @@ -37,8 +37,9 @@ import { Text } from '../../../src/ui/primitives/text'; import { HomeHeader } from '../../../src/ui/shell/home-header'; import { HostDrawerEdge } from '../../../src/ui/shell/host-drawer'; import { SettingsButton } from '../../../src/ui/shell/settings-button'; +import { ConversationPeek, conversationPeek } from '../../../src/ui/shell/conversation-peek'; import { useFloatingTabInset } from '../../../src/ui/shell/floating-tab-inset'; -import { openConversation } from '../../../src/ui/shell/open-conversation'; +import { conversationHref, openConversation } from '../../../src/ui/shell/open-conversation'; import { useChromeScroll, useScrollChrome } from '../../../src/ui/shell/scroll-chrome'; import { t } from '../../../src/ui/strings'; @@ -327,11 +328,17 @@ const HomeContent = memo(function HomeContent({ } renderItem={({ item }) => item.type === 'computer' ? ( - openConversation(router, item.conversation, saved?.hostId)} - /> + conversationPeek ? ( + + + + ) : ( + openConversation(router, item.conversation, saved?.hostId)} + /> + ) ) : ( + {children} + + + ); +} diff --git a/apps/mobile/src/ui/shell/open-conversation.ts b/apps/mobile/src/ui/shell/open-conversation.ts index 13362d1c9e..3c63c825f2 100644 --- a/apps/mobile/src/ui/shell/open-conversation.ts +++ b/apps/mobile/src/ui/shell/open-conversation.ts @@ -1,13 +1,17 @@ -import type { useRouter } from 'expo-router'; +import type { Href, useRouter } from 'expo-router'; import type { Conversation } from '../chat/conversations'; +export function conversationHref(row: Conversation, hostId?: string): Href { + return row.kind === 'bot' + ? { pathname: '/dm/[slug]', params: { slug: row.bot.slug, hostId } } + : { pathname: '/channel/[id]', params: { id: row.channel.id, hostId } }; +} + export function openConversation( router: Pick, 'push'>, row: Conversation, hostId?: string, ): void { - if (row.kind === 'bot') - router.push({ pathname: '/dm/[slug]', params: { slug: row.bot.slug, hostId } }); - else router.push({ pathname: '/channel/[id]', params: { id: row.channel.id, hostId } }); + router.push(conversationHref(row, hostId)); } diff --git a/apps/mobile/test/message-bubble.test.tsx b/apps/mobile/test/message-bubble.test.tsx index 3db38e4cde..4fdb071d7d 100644 --- a/apps/mobile/test/message-bubble.test.tsx +++ b/apps/mobile/test/message-bubble.test.tsx @@ -418,7 +418,7 @@ describe('double-tap to react', () => { }); it('leaves Android and messages that cannot take reactions alone', () => { - expect(doubleTap({ ...bot, revision: undefined })).not.toHaveBeenCalled(); + expect(doubleTap({ ...bot, delivery: 'pending' })).not.toHaveBeenCalled(); native.os = 'android'; expect(doubleTap(bot)).not.toHaveBeenCalled(); }); diff --git a/apps/mobile/test/open-conversation.test.ts b/apps/mobile/test/open-conversation.test.ts new file mode 100644 index 0000000000..a33ffacb77 --- /dev/null +++ b/apps/mobile/test/open-conversation.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { Conversation } from '../src/ui/chat/conversations'; +import { conversationHref, openConversation } from '../src/ui/shell/open-conversation'; + +const bot = { kind: 'bot', bot: { slug: 'ada' } } as unknown as Conversation; +const channel = { kind: 'channel', channel: { id: 'c1' } } as unknown as Conversation; + +describe('conversation links', () => { + it('points a Bot row at its DM and a channel row at its channel', () => { + expect(conversationHref(bot, 'h1')).toEqual({ + pathname: '/dm/[slug]', + params: { slug: 'ada', hostId: 'h1' }, + }); + expect(conversationHref(channel)).toEqual({ + pathname: '/channel/[id]', + params: { id: 'c1', hostId: undefined }, + }); + }); + + it('opens the same place the long-press preview shows', () => { + const push = vi.fn(); + openConversation({ push }, bot, 'h1'); + expect(push).toHaveBeenCalledWith(conversationHref(bot, 'h1')); + }); +}); diff --git a/changes/2364-ios-chat-qol.md b/changes/2364-ios-chat-qol.md new file mode 100644 index 0000000000..cb11ec38b3 --- /dev/null +++ b/changes/2364-ios-chat-qol.md @@ -0,0 +1,13 @@ +--- +section: Added +--- + +## en + +- On iOS, pulling up past the newest message in a chat and letting go now opens the keyboard to start typing, with a hint and a light haptic once the pull is far enough ([#2364](https://github.com/BotHarness/DeepSeekBot/pull/2364)). +- On iOS, an image on the clipboard can be attached with the system Paste button above the composer, a double-tap on a message adds or takes back 👍, and a long-press on a chat in the list previews it ([#2364](https://github.com/BotHarness/DeepSeekBot/pull/2364)). + +## zh + +- iOS 聊天页滚到最新消息后继续上拉并松手,会直接拉起输入框和键盘开始输入;拉到位时出现提示并有一次轻触反馈([#2364](https://github.com/BotHarness/DeepSeekBot/pull/2364))。 +- iOS 上剪贴板里有图片时,输入框上方会出现系统「粘贴」按钮,一点就加为附件;双击消息可点赞或取消 👍;在会话列表长按某个会话可以预览聊天内容([#2364](https://github.com/BotHarness/DeepSeekBot/pull/2364))。 diff --git a/changes/2364-pull-to-compose.md b/changes/2364-pull-to-compose.md deleted file mode 100644 index 28dfc3a6bd..0000000000 --- a/changes/2364-pull-to-compose.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -section: Added ---- - -## en - -- On iOS, pulling up past the newest message in a chat and letting go now opens the keyboard to start typing, with a hint and a light haptic once the pull is far enough ([#2364](https://github.com/BotHarness/DeepSeekBot/pull/2364)). - -## zh - -- iOS 聊天页滚到最新消息后继续上拉并松手,会直接拉起输入框和键盘开始输入;拉到位时出现提示并有一次轻触反馈([#2364](https://github.com/BotHarness/DeepSeekBot/pull/2364))。 From 9e34151cfcbaa7944c821b122c1326c88cf77e82 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:23:38 +0000 Subject: [PATCH 06/19] fix(mobile): keep the expo-router conversation link test out of the root typecheck Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TYsJ1DZQ7Vd8sKVs6MMhdF --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index 59f9abac6f..b7d32981f0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -46,6 +46,7 @@ "apps/mobile/test/inbox-screens.test.ts", "apps/mobile/test/action-cards.test.ts", "apps/mobile/test/activity-feed.test.ts", + "apps/mobile/test/open-conversation.test.ts", "apps/mobile/test/reanimated-shim.ts", "apps/mobile/test/**/*.tsx" ] From 8b3201d8743db8bc52c40cab52e8bfc1ba010a80 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:29:40 +0000 Subject: [PATCH 07/19] =?UTF-8?q?feat(mobile):=20bring=20clipboard=20paste?= =?UTF-8?q?,=20double-tap=20=F0=9F=91=8D=20and=20flick-up=20to=20type=20to?= =?UTF-8?q?=20Android?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TYsJ1DZQ7Vd8sKVs6MMhdF --- apps/mobile/DESIGN.md | 6 +-- apps/mobile/src/ui/chat/clipboard-image.tsx | 52 ++++++++++++++----- .../src/ui/chat/conversation-screen.tsx | 9 ++-- apps/mobile/src/ui/chat/message-bubble.tsx | 2 +- apps/mobile/src/ui/chat/pull-to-compose.ts | 22 ++++++-- apps/mobile/src/ui/design/tokens.ts | 2 +- apps/mobile/src/ui/strings.ts | 2 + apps/mobile/test/clipboard-image.test.tsx | 2 + apps/mobile/test/message-bubble.test.tsx | 11 ++-- apps/mobile/test/pull-to-compose.test.tsx | 30 +++++++++-- changes/2364-chat-qol.md | 13 +++++ changes/2364-ios-chat-qol.md | 13 ----- 12 files changed, 114 insertions(+), 50 deletions(-) create mode 100644 changes/2364-chat-qol.md delete mode 100644 changes/2364-ios-chat-qol.md diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index f0fc02e011..7b7acc9fee 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -168,7 +168,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - 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. - 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 with a ⌄ then floats at the trailing edge `space.x2` above the composer, aligned with its trailing edge, like Grok (a 36pt Liquid Glass circle like **+** on iOS 26 with Reduce Transparency off, otherwise `raised`), 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. - - **Pull up to type (iOS).** At the newest message, pulling the list further up past its end (the rubber-band bounce) and letting go focuses the composer and raises the keyboard, like pull-to-refresh in reverse. A drag counts only when it begins at the newest message (within the follow slop), so reading history never triggers it. A capsule with a keyboard symbol fades in above the composer as the pull grows, reading "Pull up to type" and switching to "Release to type" with one light haptic once the pull passes `pullToCompose.threshold` (64pt of offset); easing back below it before release cancels. It is off where there is no composer (a Bot-to-Bot DM) or the Host is not connected. Android has no offset past the end (its overscroll stretches instead), so it does not get this gesture. + - **Pull up to type (iOS).** At the newest message, pulling the list further up past its end (the rubber-band bounce) and letting go focuses the composer and raises the keyboard, like pull-to-refresh in reverse. A drag counts only when it begins at the newest message (within the follow slop), so reading history never triggers it. A capsule with a keyboard symbol fades in above the composer as the pull grows, reading "Pull up to type" and switching to "Release to type" with one light haptic once the pull passes `pullToCompose.threshold` (64pt of offset); easing back below it before release cancels. It is off where there is no composer (a Bot-to-Bot DM) or the Host is not connected. Android's overscroll stretches without moving the offset, so there is nothing to measure while the finger pulls: on Android an upward flick at the newest message (finger speed at least `pullToCompose.flick`, 1 dp/ms, with the list still at the newest message on release) focuses the composer with the same haptic, and no hint is drawn. - 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. @@ -189,7 +189,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - Then the actions in groups, each row with a leading system icon (`RowIcon` symbols: an SF Symbol on iOS, a Material Symbol on Android) and its label: **Copy** and **Share** (the system share sheet) in one group; when an activity has a destination, **Copy** sits on its own and **Open …** joins **Share** in the second group. On iOS each group is a `raised` card at radius `lg` with hairline dividers; on Android the groups are `ListItem`s separated by dividers. - Every size follows the system text size: rows have a touch-height minimum and grow with their padded label, icons take their size from the text style, and each reaction circle is at least touch-sized and grows with its emoji. The iOS content reports its ideal height so the fitted sheet never compresses it. - A swipe down, a tap on the scrim, Android Back or any action closes the sheet. On iOS, Share and Open wait until the sheet has fully dismissed, so the next presentation does not collide with it. - - On iOS, double-tapping a bubble that can take reactions adds 👍 (or takes yours back), like Messages' tapback, with a light haptic. The first tap does nothing on its own, so a single tap stays free; taps more than 300ms apart (`doubleTap.window`) do not count. + - Double-tapping a bubble that can take reactions adds 👍 (or takes yours back), like a tapback in Apple Messages or Google Messages, with a light haptic. The first tap does nothing on its own, so a single tap stays free; taps more than 300ms apart (`doubleTap.window`) do not count. - A message that is still sending, failed, or is a card has no reactions, and neither does a read-only Bot DM or an activity row. Bubble text is not selectable, so the long-press always reaches this sheet; screen readers reach it through the bubble's "Message actions" action. - Delivery: pending shows at 70% opacity, failed offers "Not sent · Tap to retry", and sent shows nothing. - **Action cards** sit in the chat stream, and the same rules apply wherever they appear (DM, Activity, Human Inbox, web Inbox). Show only what the Human needs to decide; everything else waits behind Details. @@ -214,7 +214,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - Android follows Material 3 (Google Messages): **+** is a standard icon button with no container and a borderless ripple, and the field is a filled capsule on the `raised` fill with no edge and no glass. The mic, Send and expand buttons ripple too. - **+** sits outside the capsule, bottom-aligned as the field grows. While dictating it becomes **×** (cancel), and while the mic is held, a red dot. With one trailing button (the mic or Send) the capsule is one row: the field and the button. When the text no longer fits that one row, or with more (attached files, or the mic next to Send), the capsule becomes a rounded rectangle with the same corner radius and stacks like the web composer: the field on top at full width, and a row below with expand on the left and the buttons on the right. - **+** opens Photos, Camera and File below the composer. Picked files show as removable tiles inside the capsule, up to 10 per message. Send appears once there is text or a file. - - On iOS, while the field is focused and the clipboard holds an image, a row above the composer reads "Image on the clipboard" with the system Paste button (`UIPasteControl`, through `expo-clipboard`'s `ClipboardPasteButton`, image only, icon only at accessibility text sizes). Pressing it attaches the image as a JPEG tile, without the system paste-permission prompt, and the row hides until something new is copied. The @ suggestions take that slot first. A text field cannot take an image paste on its own in React Native, so this is the paste path. + - While the field is focused and the clipboard holds an image, a row above the composer reads "Image on the clipboard" with a paste control. On iOS it is the system Paste button (`UIPasteControl`, through `expo-clipboard`'s `ClipboardPasteButton`, image only, icon only at accessibility text sizes), so no paste-permission prompt appears. On Android it is a text `Button` "Paste image" that reads the clipboard; Android only shows its own brief "pasted from your clipboard" notice. The image attaches as a JPEG tile and the row hides until something new is copied. The @ suggestions take that slot first. A text field cannot take an image paste on its own in React Native, so this is the paste path. - Sending uploads each file to the Host first. A retry keeps the uploads that already finished. - Nothing sits under the composer, so the chat stays clean. A DM's Model and Workspace settings live in the Bot profile, in a **Bot settings** group under the profile actions: a **Model** row (the Bot's current model name, or "Loading…", "No model set" or "Unavailable") and a **Workspace** row (the folder count, "N folders"), each with a chevron. - **Model** opens the native Models and presets page inside the profile drawer: Orchestrator, Assignment default, allowed Assignment models, reasoning effort, restore global default, save as preset and apply a shared preset. The searchable chooser pushes inside that same drawer and returns only after an accepted save and canonical refresh. Editing only the Orchestrator preserves Assignment policy. Assignment edits retain other allowed models, efforts and notes; the current default cannot be removed until another is chosen. Existing preset copies and per-Bot plans retain the Host's revision checks. Global default updates use the Host's existing profile-default contract; mobile does not create a separate global Assignment authority. diff --git a/apps/mobile/src/ui/chat/clipboard-image.tsx b/apps/mobile/src/ui/chat/clipboard-image.tsx index 2105ed3b13..3dd6ae60d6 100644 --- a/apps/mobile/src/ui/chat/clipboard-image.tsx +++ b/apps/mobile/src/ui/chat/clipboard-image.tsx @@ -4,7 +4,9 @@ import { Platform, View } from 'react-native'; import { useDesignTheme } from '../design/theme'; import { space } from '../design/tokens'; +import { Button } from '../primitives/button'; import { Text } from '../primitives/text'; +import { PhotoIcon } from '../shell/icons'; import { t } from '../strings'; type ClipboardState = { image: boolean; generation: number }; @@ -39,7 +41,16 @@ function subscribeClipboard(notify: () => void) { const unsubscribed = () => () => {}; -export const clipboardPasteAvailable = Platform.OS === 'ios' && Clipboard.isPasteButtonAvailable; +const nativePasteButton = Platform.OS === 'ios' && Clipboard.isPasteButtonAvailable; + +export const clipboardPasteAvailable = nativePasteButton || Platform.OS === 'android'; + +const IMAGE = { format: 'jpeg', jpegQuality: 0.8 } as const; + +async function readClipboardImage(onPaste: (data: string) => void) { + const image = await Clipboard.getImageAsync(IMAGE).catch(() => null); + if (image !== null) onPaste(image.data); +} export function useClipboardImage(watching: boolean) { const current = useSyncExternalStore( @@ -69,19 +80,32 @@ export function ClipboardImageOffer({ onPaste }: { onPaste(data: string): void } {t.clipboardImage} - { - if (data.type === 'image') onPaste(data.data); - }} - /> + {nativePasteButton ? ( + { + if (data.type === 'image') onPaste(data.data); + }} + /> + ) : ( + +