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
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,22 @@
## Sub-features

- `swipe`: Host DM/Channel rows reveal Pin/Unpin, Add to section and Mute/Unmute; full swipe does not execute. Vertical scrolling and iOS left-edge drawer ownership remain intact.
- `menu`: the native row menu and accessibility actions use the same callbacks; Mark as read remains available when unread. iOS conversation preview still opens the intended chat once.
- `menu`: Android long press and the iOS Link preview/context menu use the same callbacks as row accessibility actions; there is no persistent overflow button; Mark as read remains available when unread. iOS conversation preview still opens the intended chat once.
- `sections`: the native sheet reads the captured Remote Host's roster and assigns an existing section via `channelAssign`. New section uses `sectionCreate`, then the returned ID with `channelAssign`. No device-local section persistence.
- `recovery`: creation failure preserves the name; assignment failure preserves the confirmed section ID and retries assignment only; refresh failure retries refresh only. Host changes hide old choices and refuse continuation. An already-sent write stays on its original Host.

## How to get to it (user POV)

Open Chats with a paired Host and a DM or group Channel. Swipe a row left or open its actions menu. Choose Add to section, select an existing section or tap New section, enter a name and tap Create and add.
Open Chats with a paired Host and a DM or group Channel. Swipe a row left or long-press it for the full menu (with the existing preview on iOS). Choose Add to section, select an existing section or tap New section, enter a name and tap Create and add.

## Driving it with verify-app

Use the assigned [shared device QA](../../../../docs/agents/device-qa.md) runner and [Windows Android lane](../../../../docs/agents/android-device-qa.md). No model is needed. Use two disposable paired Hosts with neutral names and distinct sections; seed a DM, unread conversation and group Channel through existing CLI operations. Record PR/batch SHA, binary provenance, OS, viewport and actual OS text category/percentage.

1. Capture base and candidate on iPhone and Android at default/intermediate/maximum system text, light/dark, matching locale, viewport and test data. Capture closed row, revealed actions, open menu, section chooser, new-section keyboard and retry states. Web output is supplementary only.
1. Capture base and candidate on iPhone and Android at default/intermediate/maximum system text, light/dark, matching locale, viewport and test data. Capture closed row, fully-open settled actions, Android long-press menu, iOS preview/context menu, section chooser, new-section keyboard and retry states. No persistent overflow button or empty action column may remain. Record screen/list/ancestor clipping bounds and all three action bounds separately from hit regions; verify the trailing gutter and tap each action. Compare the header icon’s visible drawing edge with the avatar edge, not the header touch-frame boundary. Include narrow/wide viewports. Web output is supplementary only.
2. Swipe `conversation-row-<key>` left outside the edge. Verify exactly three actions, no execution on a full swipe, and that each tap closes the rail without opening the conversation. Exercise Pin/Unpin and Mute/Unmute via both swipe and the menu; read the Host's roster/mute facts and compare after refresh/relaunch. Cloud rows and Bots without an existing DM must not receive Host actions.
3. On iOS start a rightward swipe inside 24 points (`EDGE_WIDTH`), just inside/outside its boundary, over multiple row positions. The drawer must win inside the zone; row swipe cannot activate there in either direction. Check vertical scroll, swipe-close and tap-close outside the edge. Long-press for the existing chat preview and open it; one tap/preview commit opens one destination.
4. With VoiceOver/TalkBack, open the row's named actions menu and run each action without swiping. Check row accessibility actions too; hidden rail buttons must not duplicate focus stops. Mark as read remains available for an unread row. Use Reduce Motion and Android ripple; repeat long names and orientation changes.
4. With VoiceOver/TalkBack, use the row’s custom accessibility actions to run every action without swiping or long pressing. Also check the platform context menu; hidden rail buttons must not duplicate focus stops. Mark as read remains available for an unread row. Use Reduce Motion and Android ripple; repeat long names and orientation changes.
5. Add to section opens the owning Host's roster sections. Choose one and read `roster-get` on that Host: the Channel belongs to the selected section. Repeat for a Bot DM and a group. Desktop changes must appear through the existing refresh/subscription path. Collapsed sections/pins may affect where Chats shows the row.
6. On an empty section list, New section must remain available. Enter a long localized name with the actual soft keyboard, CJK composition, caret movement and live default→maximum→default size changes. Reach Create and add and Back without hiding text under the keyboard. Verify exactly one section and the membership on the originating Host. Reject whitespace; cancel before submit writes nothing. Reopen and confirm Host persistence.
7. Delay/fail `channelAssign` after successful `sectionCreate` in a disposable QA transport. The sheet shows the saved name and assignment error. Retry must use that ID, never another create; count sections on the Host. Fail refresh after assignment: Retry must not repeat writes. A failed create retains the draft and offers Back to inspect refreshed sections. Deterministic transport tests cover exact call ordering; screenshots alone cannot prove it.
Expand All @@ -30,3 +30,4 @@ Use the assigned [shared device QA](../../../../docs/agents/device-qa.md) runner
- `sectionCreate` has no idempotency key. A lost creation response requires inspecting the Host list before a fresh create; the confirmed-create/failed-assign path has a safe ID-preserving retry.
- This flow creates and assigns sections only. Rename/delete/reorder and cross-Host aggregation are out of scope.
- No shared devices, Metro, QA Hosts or builds may start outside the owner-cut batch. The Windows machine cannot substitute source tests for iPhone acceptance.
- The previous Android capture showed three fully visible action bounds and a trailing gap; it does not rule out another state’s ancestor clipping or establish hit-testing. Do not label the gutter adjustment a proven overflow fix before the native loop runs.
3 changes: 2 additions & 1 deletion apps/mobile/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w
- The header has a compact Host title on the left and a Settings gear on the right, on both iOS and Android. Search and Settings are `HeaderAction` buttons, native to each platform: on iOS 26 and later they are SwiftUI Liquid Glass circles (`buttonStyle('glass')`) with SF Symbols (`magnifyingglass`, `gearshape`), so the system supplies the press, Reduce Transparency and Increase Contrast looks. The symbol follows Dynamic Type only up to `xxxLarge`, as system toolbar symbols do, so at accessibility sizes it stays inside its fixed glass circle while the button keeps its label; earlier iOS keeps the opaque `raised` circles. Android uses Material 3 standard icon buttons: no container, a 40dp circular ripple (`materialIconButton.container`) inside the 48dp touch target, like the actions of a Material top app bar. The Android search back and clear buttons use the same icon button. This row stays horizontal at every system text size: the Host chip shrinks, its name occupies one line with tail ellipsis, and the Settings target stays at the trailing edge. Keep native font scaling and the full accessible Host name; both targets retain the platform minimum touch size. Other Host-name presentations may wrap. The Host picker stays mounted between visits; its translation follows a critically damped UI-thread spring and its scrim is clamped. Hidden contents leave the accessibility tree.
- **+** is the circle beside the bottom navigation island. It opens the native **New PersonaBot** / **New Channel** menu and uses the app's create flow, or the full client where the app has none yet.
- **Search** is a magnifying-glass button just left of the Settings gear. Tapping it turns the whole header row into the search field and hides the Host title and Settings, as a Material top app bar opens its search and as iOS expands a toolbar search from its button. The field is the shared navigation glass capsule (`DrawerMaterial`; opaque raised on older iOS, Reduce Transparency and Android) and focuses at once. iOS closes it with the native circular close button at the trailing end and keeps the system clear button inside the field. Android leads with a back arrow, adds its own clear button while there is a query, and system Back closes search. Closing clears the query and restores the title and Settings. Ending editing with an empty query closes search; with a query, the field stays in the header so the filtered list keeps showing what it matches. Navigation stays visible while the field is focused or holds a query. Nothing floats above the bottom navigation, and the list reserves only the navigation's height. There are no shortcut cards: Activity already gathers what needs the Human.
- On iOS and Android, swiping a Host conversation row left reveals **Pin / Unpin**, **Add to section**, and **Mute / Unmute**. ReanimatedSwipeable owns the gesture on the UI thread; the iOS recognizer excludes the drawer's `EDGE_WIDTH` so a swipe cannot start there. Reveal uses existing 56-point icon targets (Android keeps 16dp corners and ripple), with full localized accessibility labels; a full swipe never commits an action. The native row menu and row accessibility actions expose the same callbacks, with **Mark as read** retained when applicable. iOS keeps its native conversation preview inside the row, separate from the menu trigger. Hidden swipe buttons leave the accessibility tree. Cloud rows have no Host swipe operations.
- On iOS and Android, swiping a Host conversation row left reveals **Pin / Unpin**, **Add to section**, and **Mute / Unmute**. ReanimatedSwipeable owns the gesture on the UI thread; the iOS recognizer excludes the drawer's `EDGE_WIDTH` so a swipe cannot start there. Reveal uses existing 56-point icon targets (Android keeps 16dp corners and ripple), with full localized accessibility labels; a full swipe never commits an action. Chats rows have no persistent overflow button or reserved menu column. Android long press opens the full native action menu; iOS places the full menu in the existing Link preview/context menu without a competing long-press handler. Row accessibility actions expose the same callbacks, with **Mark as read** retained when applicable. This Human-approved row exception keeps secondary commands in swipe/context/accessibility actions while preserving iOS preview. Hidden swipe buttons leave the accessibility tree. Cloud rows have no Host swipe operations.
- Chats uses the shared `chatList.gutter` (16 points) for the header, row content and swipe rail. The row compensates for the avatar slot’s 3-point inset so the avatar starts at the content gutter; the header icon container starts at the same gutter. Preserve each native glyph’s own drawing bounds. The rail retains its trailing gutter and 56-point targets. Verify visible glyph/avatar alignment, actual fully-open clipping and hit regions on device; matching layout constants or accessibility bounds alone do not prove optical alignment or explain the reported clipping. Keep #2554’s horizontal title/time and single-line preview at large text.
- **Add to section** opens a native form sheet bound to that conversation's Remote Host and Channel. The virtualized chooser reads the same Host roster snapshot/subscription as Chats; sections are persisted on that Host, never in device-local storage or a cross-Host section list. Choose an existing section or use **New section** to enter a name in a keyboard-aware form and **Create and add**. Names are trimmed and must be nonempty, as on desktop. `sectionCreate` returns the Host's section ID, then `channelAssign` adds the conversation, and the existing Host refresh updates Chats. Pin/mute/read retain `rosterBatch`, `channelMuteSet` and `channelMarkRead`; no new Host API or CLI operation is introduced.
- The section sheet captures its Host client; a Host switch hides stale choices and refuses each further write/continuation. Busy feedback appears immediately, and rapid submission is guarded. Creation, assignment and refresh failures have distinct messages. A confirmed section ID survives assignment failure in the open sheet, so Retry only assigns it; a refresh retry does not repeat writes. A creation failure keeps the draft and directs the Human to check the refreshed Host list before trying again, because the existing create API has no idempotency key. Dismissing an in-flight create cannot undo a completed Host write; the section remains available from that Host's roster on reopening. Native keyboard, large-text, screen-reader and gesture acceptance follows the matrix below.
- One list of PersonaBot DMs and Channels together, grouped by the Host's **Channel sections** as on the desktop sidebar:
Expand Down
68 changes: 35 additions & 33 deletions apps/mobile/src/ui/chat/conversation-row.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { ReactNode, Ref } from 'react';
import type { Href } from 'expo-router';
import { SourceName } from './source-name';
import { Pressable, View, type ViewInstance } from 'react-native';
import { Platform, Pressable, View, type ViewInstance } from 'react-native';

import { BotAvatar } from '../bot-avatar';
import type { HostLook } from '../design/host-look';
import { edge, radius, space } from '../design/tokens';
import { chatList, edge, radius, space } from '../design/tokens';
import { useDesignTheme } from '../design/theme';
import { CountBadge } from '../primitives/count-badge';
import { Text } from '../primitives/text';
Expand All @@ -24,14 +24,13 @@ import {
import { RowStatusGlyph } from './row-status-glyph';
import type { ConversationAction, ConversationActionKey } from './conversation-actions';
import { ConversationSwipe } from './conversation-swipe';
import { ConversationPeek } from '../shell/conversation-peek';
import { ActionMenuButton, type MenuAction } from '../primitives/action-menu-button';
import { ConversationPeek, type ConversationPeekAction } from '../shell/conversation-peek';
import { showActionMenu } from '../primitives/app-dialog';

const AVATAR = 48;
const RING = 6;

export const AVATAR_EDGE = space.x5 + RING / 2;
export const AVATAR_EDGE = chatList.gutter;

export function ConversationRow({
row,
Expand Down Expand Up @@ -65,17 +64,20 @@ export function ConversationRow({
const time = shortTime(row.at, now);
const label = rowLabel(row, title, status, preview);
const act = onAction ?? (() => undefined);
const theme = useDesignTheme();
const menuActions: MenuAction[] = actions.map((action) => ({
const availableActions = onAction ? actions : [];
const menuActions: ConversationPeekAction[] = availableActions.map((action) => ({
key: action.key,
label: action.label,
icon:
action.key === 'section'
? 'folder'
: action.key === 'read'
? 'check'
? 'checkmark'
: action.key === 'pin' || action.key === 'unpin'
? 'pin'
: 'mute',
: action.key === 'mute'
? 'bell.slash'
: 'bell',
run: () => act(action.key),
}));
const frame = (
Expand All @@ -93,6 +95,18 @@ export function ConversationRow({
unread={row.unread > 0 && !row.muted}
badge={badge}
onPress={previewHref ? undefined : onPress}
onLongPress={
Platform.OS === 'android' && availableActions.length > 0
? () =>
showActionMenu(
title,
availableActions.map((action) => ({
text: action.label,
onPress: () => act(action.key),
})),
)
: undefined
}
avatar={
row.kind === 'bot' ? (
<AvatarSlot>
Expand All @@ -106,29 +120,13 @@ export function ConversationRow({
);
return (
<ConversationSwipe actions={onAction ? actions : []} onAction={act}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.layer.canvas.fill,
}}
>
<View style={{ flex: 1, minWidth: 0 }}>
{previewHref ? <ConversationPeek href={previewHref}>{frame}</ConversationPeek> : frame}
</View>
{onAction && actions.length > 0 ? (
<ActionMenuButton
label={t.conversationActions(title)}
actions={menuActions}
onOpen={() =>
showActionMenu(
title,
actions.map((action) => ({ text: action.label, onPress: () => act(action.key) })),
)
}
/>
) : null}
</View>
{previewHref ? (
<ConversationPeek href={previewHref} actions={menuActions}>
{frame}
</ConversationPeek>
) : (
frame
)}
</ConversationSwipe>
);
}
Expand Down Expand Up @@ -167,6 +165,7 @@ export function ConversationRowFrame({
badge,
avatar,
onPress,
onLongPress,
disabled = false,
actions = [],
onAction,
Expand All @@ -188,6 +187,7 @@ export function ConversationRowFrame({
badge?: ReturnType<typeof countBadge>;
avatar: ReactNode;
onPress?: (() => void) | undefined;
onLongPress?: (() => void) | undefined;
disabled?: boolean;
testID?: string;
}) {
Expand All @@ -197,6 +197,7 @@ export function ConversationRowFrame({
ref={ref}
testID={testID}
onPress={onPress}
onLongPress={onLongPress}
disabled={disabled || onPress === undefined}
accessibilityRole={onPress === undefined ? undefined : 'button'}
accessibilityLabel={label}
Expand All @@ -210,7 +211,8 @@ export function ConversationRowFrame({
alignItems: 'center',
gap: space.x4,
minHeight: theme.touch,
paddingHorizontal: space.x5,
paddingLeft: chatList.gutter - RING / 2,
paddingRight: chatList.gutter,
paddingVertical: space.x3,
backgroundColor: pressed ? theme.layer.canvas.pressed : theme.layer.canvas.fill,
})}
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/ui/chat/conversation-swipe-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import ReanimatedSwipeable, {
} from 'react-native-gesture-handler/ReanimatedSwipeable';

import { pressTint, useDesignTheme } from '../design/theme';
import { radius, space, swipeAction } from '../design/tokens';
import { chatList, radius, space, swipeAction } from '../design/tokens';
import { ActivityIcon, CheckIcon, FolderIcon, MuteIcon, PinIcon } from '../shell/icons';
import { EDGE_WIDTH } from '../shell/host-drawer-model';
import type { ConversationAction, ConversationActionKey } from './conversation-actions';
Expand Down Expand Up @@ -51,7 +51,7 @@ export function ConversationSwipe({
flexDirection: 'row',
alignItems: 'center',
gap: space.x3,
paddingHorizontal: space.x5,
paddingHorizontal: chatList.gutter,
}}
>
{actions
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/ui/design/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const fab = {
export const materialDrawer = { width: 360, scrimGutter: 56, addressMinScale: 0.6 } as const;
export const loadingIndicator = { size: 48, control: 20, controlStroke: 2.5 } as const;
export const swipeAction = { size: 56 } as const;
export const chatList = { gutter: space.x5 } as const;
export const environmentTab = { widthRatio: 0.6 } as const;
export const tabLabel = { minimumFontScale: 0.6 } as const;
export const modalRail = { maxWidth: 360 } as const;
Expand Down
Loading
Loading