diff --git a/.agents/skills/verify-deepseekbot/features/README.md b/.agents/skills/verify-deepseekbot/features/README.md index c0c7214432..9e5121f7e9 100644 --- a/.agents/skills/verify-deepseekbot/features/README.md +++ b/.agents/skills/verify-deepseekbot/features/README.md @@ -31,6 +31,7 @@ One file per user-facing feature. Each file has four sections: `Sub-features`, ` | Stop a working Bot (Web Client) | yes | `scripts/e2e-web-stop.mjs` | [bot-stop.md](bot-stop.md) | | Phone pairing and paired devices | no | `scripts/e2e-mobile-pairing-web.mjs`, `scripts/e2e-device-pairing.mjs`, `scripts/e2e-mobile-sim.mjs`, `scripts/e2e-mobile-pair-android.mjs` | [phone-pairing.md](phone-pairing.md) | | Phone Chats drawer views and Android Host accent | no | none (shared-device native path) | [phone-drawer-views.md](phone-drawer-views.md) | +| Phone Chats swipe actions and Remote Host section creation | no | none (shared-device native path) | [phone-chat-row-actions.md](phone-chat-row-actions.md) | | Native mobile message composer | no (except Bot reply) | none (shared-device native path) | [phone-composer.md](phone-composer.md) | | Native mobile chat backgrounds | no (except live-reply regression) | none (shared-device native path) | [phone-chat-backgrounds.md](phone-chat-backgrounds.md) | | Android launch, sheets, shell and chat presentation | no | Launch: queued native check; interactions: `scripts/e2e-mobile-android-interactions.mjs` | [phone-android-interactions.md](phone-android-interactions.md) | diff --git a/.agents/skills/verify-deepseekbot/features/phone-chat-row-actions.md b/.agents/skills/verify-deepseekbot/features/phone-chat-row-actions.md new file mode 100644 index 0000000000..d60d9fad09 --- /dev/null +++ b/.agents/skills/verify-deepseekbot/features/phone-chat-row-actions.md @@ -0,0 +1,32 @@ +# Phone Chats row actions and Host sections + +## 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. +- `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. + +## 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. +2. Swipe `conversation-row-` 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. +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. +8. Switch/unpair the owning Host while the sheet is open and during a delayed create. Old choices disappear and further actions refuse; no assignment goes to the new Host. A creation already accepted by the original Host may remain there. Reopen from the new Host and see only its sections. Restore device settings and remove only this task's disposable fixtures. + +## Gotchas + +- Native iPhone/Android gestures, native menus, keyboard clearance, screen-reader focus and large-text geometry remain unrun until the designated runner records them; unit tests mock native adapters. +- `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. diff --git a/.github/pr-screenshots/2552-android/README.md b/.github/pr-screenshots/2552-android/README.md new file mode 100644 index 0000000000..ec002906f4 --- /dev/null +++ b/.github/pr-screenshots/2552-android/README.md @@ -0,0 +1,38 @@ +# PR2552 Android native evidence — PARTIAL + +Actual run: 2026-10-22, Android 16 arm64 physical phone, 1220 × 2712 screenshot pixels. This is development-native behavior, not Release performance, iOS, TalkBack or Human acceptance. + +## Exact inputs and provenance + +- Baseline main: `897e9334768baf26547eb155f9ab1b3e7a0ee61f`. +- Combined candidate: `8e09ba57d367dff346a4ab5f3c5d53e5c32c90e6`. +- PR2551: `5df949ff7fad82f128609a858277f161df484bcb` (tests/docs; streaming fixes already on baseline). +- PR2552: `a0a3f17af55af20e48a8c4bb1d47430bda7b7b99`. +- PR2554: `571b4437b4a5c323d243a56e79d25d5bf85f82b8`. +- Both independently computed native fingerprints: `2cde0026048320af2a7fdcafdc10b68875aadd75` (211 sources). Expo58.0.17 / RN0.88.0 / React19.3.0. Reused compatible arm64 development APK, SHA256 `B3A5DF7CA0D0F1C393523BE945FD49EF7263110A6DAA5B89726073759CA9ED3A`, 115259560 bytes. Historical native-build Node24.16/24.21 provenance uncertainty remains; actual Host/Metro runtime explicitly Node24.21.0. No new native build in this run. +- Preparation: 20 files / 163 tests and mobile typecheck passed. Separate baseline/candidate root builds passed; one sequential Metro and isolated QA Hosts. These source/build results are not native acceptance. +- Official provider/model: `deepseek-official` / `deepseek-flash`, verified with actual phone composer → Host → visible reply, not API-only. USB loopback QA route does not prove tailnet or QR scanning. + +## PR2552 — PARTIAL; native operations and canonical ownership sampled + +PASS sampled: full left swipe exposed actions and left canonical roster unchanged. Explicit native pin/mute/unpin/unmute changed the intended DM canonical fields. Native Android overflow menu also opened for a group. Empty section chooser worked; actual soft-keyboard pinyin composition selected `测试`, followed by `2552`; field and create control remained visible above keyboard. Create+assign produced exactly one `测试2552` section with the intended DM; pin was retained per this frozen PR's original contract. Later group assignment added the group to the same canonical section. Unpin/unmute preserved section membership. + +Capture qualification: at07:31:47.306Z, about1.4s after create tap, the create sheet showed the selected name and a `重试` button without a visible error; the07:32:25 snapshot was back on Chats and Host state was successful. Source selects the Retry label whenever `state.selected` exists, including intermediate assignment. This early frame alone neither proves a persistent failure nor verifies the deliberate failure/retry journey. Preserve it as a transient-state observation. + +Host switch: unsent draft `255299` on QA Host A was dismissed by switching to the other owned QA Host. That Host chooser exposed only `Baseline Only`; canonical state showed no `255299` section on either Host and no cross-Host assignment. This is an unsent-draft switch case, not delayed in-flight create/refusal verification. + +UNTESTED: controlled create-success/assignment-failure retry, refresh failure, offline draft retention, in-flight Host refusal, TalkBack/hidden-rail focus, Reduce Motion, unread mark-read, full font/theme/rotation and form-caret matrix. Native labels sampled only. New organization design, Organize mode, Pinned grid and unpin-on-assign are excluded; original pin-preserving behavior cannot qualify the newer spec. + +cjk.mp4 is the first59seconds of actual soft-keyboard composition at original speed. It ends before submission and does not prove successful create/assign; Host facts and subsequent snapshots establish that separately. created.png is the early selected-name/Retry intermediate state; it must not be captioned final failure or final success. + +Status/system-navigation pixels cropped for privacy. No generated/reconstructed UI. PNGs reviewed individually through labeled contact sheets; recording frames sampled every5s across their full selected interval. No personal notification or credential visible in reviewed frames. Public clips only contain designated QA journeys; raw originals remain private. This sampling is not frame-by-frame privacy certification. + +## Restoration and exclusions + +Original development APK restored with install-r and pullback SHA256 `51602EFA76CF63081C664BF9F53B857F843F91B377F99EFDCA6DC699C9809766` matching backup. All eight recorded settings and original ADB reverse list matched; own pairings removed and all three issued credentials revoked. Two original offline Host entries remain and phone preferences were all true. Pre-run drawer-host IDs were missing, so exact original-pairing ID comparison is unavailable; earlier `exactMatch=false` was an invalid empty-baseline comparison, not proof of data loss. Original offline Host copies remain pending. No uninstall, pm-clear or production app changes. + +Own Host/Metro processes, descendants, ports and recorders were stopped. Lease and heavy slot released07:53:03.733Z. Evidence: restore-device.json, cleanup-runtime.json, recorders-cleanup.json, resource-release.json and pairings-restore-qualified.json. Global ADB untouched. + +Telemetry2570 remained BLOCKED: no independent production-egress isolation or dedicated synthetic container. No analytics configuration, interception, statistics-file mutation or personal opt-out operation occurred. Hermes2565, HostSettings2564, grid2574, Video and native splash2579 were excluded. Splash2579 is accepted for a future main batch containing merge55fe0fa36669e21d343bec7e417b2387d239118f and requires a new native APK; old binary/Metro cannot verify it. + +Public images are pixel crops removing status/navigation bars only, with hashes and source binding. Raw logs, identifiers, private profiles, XML and unreviewed frames remain private. Public publication status is tracked in the sole queue; local assets alone do not complete PR evidence delivery. diff --git a/.github/pr-screenshots/2552-android/before.png b/.github/pr-screenshots/2552-android/before.png new file mode 100644 index 0000000000..77b4680f72 Binary files /dev/null and b/.github/pr-screenshots/2552-android/before.png differ diff --git a/.github/pr-screenshots/2552-android/chooser.png b/.github/pr-screenshots/2552-android/chooser.png new file mode 100644 index 0000000000..42c38ecdd0 Binary files /dev/null and b/.github/pr-screenshots/2552-android/chooser.png differ diff --git a/.github/pr-screenshots/2552-android/cjk.mp4 b/.github/pr-screenshots/2552-android/cjk.mp4 new file mode 100644 index 0000000000..9699d11e8b Binary files /dev/null and b/.github/pr-screenshots/2552-android/cjk.mp4 differ diff --git a/.github/pr-screenshots/2552-android/cjk.png b/.github/pr-screenshots/2552-android/cjk.png new file mode 100644 index 0000000000..aeccc90bc4 Binary files /dev/null and b/.github/pr-screenshots/2552-android/cjk.png differ diff --git a/.github/pr-screenshots/2552-android/created.png b/.github/pr-screenshots/2552-android/created.png new file mode 100644 index 0000000000..de2eef82f7 Binary files /dev/null and b/.github/pr-screenshots/2552-android/created.png differ diff --git a/.github/pr-screenshots/2552-android/group.png b/.github/pr-screenshots/2552-android/group.png new file mode 100644 index 0000000000..6c4dffa8dd Binary files /dev/null and b/.github/pr-screenshots/2552-android/group.png differ diff --git a/.github/pr-screenshots/2552-android/hashes.json b/.github/pr-screenshots/2552-android/hashes.json new file mode 100644 index 0000000000..0f90ee3cc2 --- /dev/null +++ b/.github/pr-screenshots/2552-android/hashes.json @@ -0,0 +1,56 @@ +[ + { + "file": "before.png", + "bytes": 64207, + "sha256": "7d2f7622761565198114ef91868798f0d1e4dd0235d36f8cdc8909200b82e42e", + "processing": "Original1220x2712 crop x0/y130/w1220/h2530 only." + }, + { + "file": "chooser.png", + "bytes": 65419, + "sha256": "cce7481850f6bfea814de0c4204f2e52cd9cacde8a79f894b9ab5c2885ed22eb", + "processing": "Original1220x2712 crop x0/y130/w1220/h2530 only." + }, + { + "file": "cjk.mp4", + "bytes": 149555, + "sha256": "e82574ecbd668d8daeae14ea6f8677aa3ceef66f22786a69743f43a6892bc924", + "processing": "Original1220x2712 crop x0/y130/w1220/h2530, scale610x1264, H264, no audio, original playback speed." + }, + { + "file": "cjk.png", + "bytes": 136976, + "sha256": "3484cbfcf08337ea6ce2d7dae1ca7f1daba34da8937c2ab05aa58db95fcf88eb", + "processing": "Original1220x2712 crop x0/y130/w1220/h2530 only." + }, + { + "file": "created.png", + "bytes": 51583, + "sha256": "85d20d827d13af32c3a28bd2c67e336ccb10802ae753cfbea690c998dc5c8d77", + "processing": "Original1220x2712 crop x0/y130/w1220/h2530 only." + }, + { + "file": "group.png", + "bytes": 58820, + "sha256": "dd42a7899f8affba7e15855cb6759781646fd2e100164ffb488242646ca79af9", + "processing": "Original1220x2712 crop x0/y130/w1220/h2530 only." + }, + { + "file": "menu.png", + "bytes": 118469, + "sha256": "87ffbcd1eee4c14ae4779bc744e1a125140523e4ac5ab172a34be0865a1eb515", + "processing": "Original1220x2712 crop x0/y130/w1220/h2530 only." + }, + { + "file": "other-host.png", + "bytes": 59894, + "sha256": "9ebaa9ac6f8a81c64e07729b60fe58bb9a4e7ccaa8f7984309c640afbbf73a53", + "processing": "Original1220x2712 crop x0/y130/w1220/h2530 only." + }, + { + "file": "swipe.png", + "bytes": 84456, + "sha256": "1e9dcf42456a7c1edc172777332012f9389f02c36658e414e1eaca99492192d1", + "processing": "Original1220x2712 crop x0/y130/w1220/h2530 only." + } +] diff --git a/.github/pr-screenshots/2552-android/menu.png b/.github/pr-screenshots/2552-android/menu.png new file mode 100644 index 0000000000..321a8eb655 Binary files /dev/null and b/.github/pr-screenshots/2552-android/menu.png differ diff --git a/.github/pr-screenshots/2552-android/other-host.png b/.github/pr-screenshots/2552-android/other-host.png new file mode 100644 index 0000000000..49a779df7e Binary files /dev/null and b/.github/pr-screenshots/2552-android/other-host.png differ diff --git a/.github/pr-screenshots/2552-android/swipe.png b/.github/pr-screenshots/2552-android/swipe.png new file mode 100644 index 0000000000..025b37309c Binary files /dev/null and b/.github/pr-screenshots/2552-android/swipe.png differ diff --git a/.github/pr-screenshots/2554-android/README.md b/.github/pr-screenshots/2554-android/README.md index 61d61170d8..843a51e5c1 100644 --- a/.github/pr-screenshots/2554-android/README.md +++ b/.github/pr-screenshots/2554-android/README.md @@ -13,7 +13,6 @@ Actual run: 2026-10-22, Android 16 arm64 physical phone, 1220 × 2712 screenshot - Preparation: 20 files / 163 tests and mobile typecheck passed. Separate baseline/candidate root builds passed; one sequential Metro and isolated QA Hosts. These source/build results are not native acceptance. - Official provider/model: `deepseek-official` / `deepseek-flash`, verified with actual phone composer → Host → visible reply, not API-only. USB loopback QA route does not prove tailnet or QR scanning. - ## PR2554 — sampled geometry improved; full acceptance remains PARTIAL Baseline and candidate share the same HarborBot/42 data for row captures. Default/intermediate1.3 and extra2.0 stress light/dark captured. Baseline2.0 places title, time and preview on separate lines. Candidate places title/time on the same horizontal line with preview/status below and real PR2552 trailing menu. At2.0 dark, title/time are still truncated; this is not a full-content-visibility pass. @@ -22,9 +21,6 @@ Actual OEM maximum was selected on the system font slider: label200% maps to And Live font changes produced Expo Router linking/navigation warnings in both sources. Warnings remain visible in published full-app crops, with no attribution to these PRs. Some warnings overlap bottom navigation. Long-title row opening, complete accessibility and iOS/Human acceptance remain untested. - - - Status/system-navigation pixels cropped for privacy. No generated/reconstructed UI. PNGs reviewed individually through labeled contact sheets; recording frames sampled every5s across their full selected interval. No personal notification or credential visible in reviewed frames. Public clips only contain designated QA journeys; raw originals remain private. This sampling is not frame-by-frame privacy certification. ## Restoration and exclusions @@ -36,4 +32,3 @@ Own Host/Metro processes, descendants, ports and recorders were stopped. Lease a Telemetry2570 remained BLOCKED: no independent production-egress isolation or dedicated synthetic container. No analytics configuration, interception, statistics-file mutation or personal opt-out operation occurred. Hermes2565, HostSettings2564, grid2574, Video and native splash2579 were excluded. Splash2579 is accepted for a future main batch containing merge55fe0fa36669e21d343bec7e417b2387d239118f and requires a new native APK; old binary/Metro cannot verify it. Public images are pixel crops removing status/navigation bars only, with hashes and source binding. Raw logs, identifiers, private profiles, XML and unreviewed frames remain private. Public publication status is tracked in the sole queue; local assets alone do not complete PR evidence delivery. - diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index 0638d9de35..e92431b4fc 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -107,12 +107,14 @@ 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 Android, swiping a Computer conversation row left reveals Material 3 list actions as 56dp icon buttons with 16dp corners: Mute or Unmute (on this computer), Pin or Unpin, and, when it has unread messages, Mark as read as the end-aligned primary action in the accent colour. They use the existing Host methods (`channelMuteSet`, `rosterBatch`, `channelMarkRead`; CLI `channel-mute`, `roster-batch`, `channel-mark-read`). The same actions are accessibility actions on the row, so TalkBack reaches them without the swipe. Cloud rows have no swipe actions yet. A full swipe does not trigger an action. + - 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. + - **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: - Sections come in the desktop's order. Ungrouped DMs and Channels sit between them in the desktop's top-level order. - A section header shows its name and Channel count and collapses or expands on tap. A collapsed section still shows a "Needs you" mark when something inside needs the Human. The collapse state is kept on the phone, per Host. - Hidden Channels stay out of the list. Pins and the sort mode follow `CONTEXT.md`. - - Changes made on the desktop show when the roster refreshes. Creating, renaming, reordering and moving Channels between sections stays on the desktop for now. + - Changes made on the desktop show when the roster refreshes. Mobile can create sections and assign conversations through **Add to section**; renaming, deleting and reordering sections remain outside this flow. - A Host with no sections shows one flat list by latest activity. - **Search** finds a PersonaBot or Channel on the current Host: - Typing in the Chats search field filters the list in place, as chat apps do; there is no separate search tab. diff --git a/apps/mobile/app/(tabs)/(home)/index.tsx b/apps/mobile/app/(tabs)/(home)/index.tsx index 4875c37598..7d0e890db4 100644 --- a/apps/mobile/app/(tabs)/(home)/index.tsx +++ b/apps/mobile/app/(tabs)/(home)/index.tsx @@ -42,7 +42,7 @@ import { HeaderAction } from '../../../src/ui/shell/header-action'; import { HeaderSearch } from '../../../src/ui/shell/header-search'; import { HomeHeader } from '../../../src/ui/shell/home-header'; import { HostDrawerEdge } from '../../../src/ui/shell/host-drawer'; -import { ConversationPeek, conversationPeek } from '../../../src/ui/shell/conversation-peek'; +import { conversationPeek } from '../../../src/ui/shell/conversation-peek'; import { useFloatingTabInset } from '../../../src/ui/shell/floating-tab-inset'; import { conversationHref, openConversation } from '../../../src/ui/shell/open-conversation'; import { useChromeScroll } from '../../../src/ui/shell/scroll-chrome'; @@ -407,31 +407,40 @@ const HomeContent = memo(function HomeContent({ } renderItem={({ item }) => item.type === 'computer' ? ( - conversationPeek ? ( - - - - ) : ( - openConversation(router, item.conversation, saved?.hostId)} - actions={conversationActions(item.conversation, { - pinned: item.pinned, - hostMuted: - snapshot.roster?.arrangement?.muted?.includes( - conversationChannelId(item.conversation) ?? '', - ) === true, - })} - onAction={(key) => { - if (client === undefined) return; - runConversationAction(client, inbox, item.conversation, key).catch(() => - showAppDialog(t.rowActions.failed, undefined, [{ text: t.done }]), - ); - }} - /> - ) + openConversation(router, item.conversation, saved?.hostId)} + actions={conversationActions(item.conversation, { + pinned: item.pinned, + hostMuted: + snapshot.roster?.arrangement?.muted?.includes( + conversationChannelId(item.conversation) ?? '', + ) === true, + })} + onAction={(key) => { + if (client === undefined) return; + if (key === 'section') { + const channelId = conversationChannelId(item.conversation); + if (channelId && saved) + router.push({ + pathname: '/conversation-section', + params: { hostId: saved.hostId, channelId }, + }); + return; + } + runConversationAction(client, inbox, item.conversation, key).catch(() => + showAppDialog(t.rowActions.failed, undefined, [{ text: t.done }]), + ); + }} + /> ) : ( + }} diff --git a/apps/mobile/app/conversation-section.tsx b/apps/mobile/app/conversation-section.tsx new file mode 100644 index 0000000000..186c70a579 --- /dev/null +++ b/apps/mobile/app/conversation-section.tsx @@ -0,0 +1,28 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useState } from 'react'; + +import { hostSession, useHostSession } from '../src/host/host-session'; +import { ConversationSectionSheet } from '../src/ui/chat/conversation-section-sheet'; +import { SheetScreen } from '../src/ui/primitives/sheet-screen'; + +export default function ConversationSectionScreen() { + const { hostId, channelId } = useLocalSearchParams<{ hostId: string; channelId: string }>(); + const session = useHostSession(); + const router = useRouter(); + const [owner] = useState(() => (session.saved?.hostId === hostId ? session.client : undefined)); + const isCurrent = () => { + const current = hostSession.getState(); + return owner !== undefined && current.client === owner && current.saved?.hostId === hostId; + }; + return ( + + router.back()} + /> + + ); +} diff --git a/apps/mobile/src/ui/chat/conversation-actions.ts b/apps/mobile/src/ui/chat/conversation-actions.ts index 02f0d6e0fc..7bb9154b4f 100644 --- a/apps/mobile/src/ui/chat/conversation-actions.ts +++ b/apps/mobile/src/ui/chat/conversation-actions.ts @@ -3,7 +3,7 @@ import type { Inbox } from '../../host/inbox'; import { t } from '../strings'; import type { Conversation } from './conversations'; -export type ConversationActionKey = 'pin' | 'unpin' | 'read' | 'mute' | 'unmute'; +export type ConversationActionKey = 'pin' | 'unpin' | 'section' | 'read' | 'mute' | 'unmute'; export type ConversationAction = { key: ConversationActionKey; label: string }; @@ -23,8 +23,9 @@ export function conversationActions( ): ConversationAction[] { if (conversationChannelId(conversation) === undefined) return []; const keys: ConversationActionKey[] = [ - state.hostMuted ? 'unmute' : 'mute', state.pinned ? 'unpin' : 'pin', + 'section', + state.hostMuted ? 'unmute' : 'mute', ]; if (conversation.unread > 0 && latestMessageId(conversation) !== undefined) keys.push('read'); return keys.map((key) => ({ key, label: t.rowActions[key] })); @@ -38,6 +39,7 @@ export async function runConversationAction( ): Promise { const channelId = conversationChannelId(conversation); if (channelId === undefined) return; + if (key === 'section') return; if (key === 'read') { const messageId = latestMessageId(conversation); if (messageId === undefined) return; diff --git a/apps/mobile/src/ui/chat/conversation-row.tsx b/apps/mobile/src/ui/chat/conversation-row.tsx index e977458fec..10c55c1be0 100644 --- a/apps/mobile/src/ui/chat/conversation-row.tsx +++ b/apps/mobile/src/ui/chat/conversation-row.tsx @@ -1,6 +1,7 @@ -import type { ReactNode } from 'react'; +import type { ReactNode, Ref } from 'react'; +import type { Href } from 'expo-router'; import { SourceName } from './source-name'; -import { Pressable, View } from 'react-native'; +import { Pressable, View, type ViewInstance } from 'react-native'; import { BotAvatar } from '../bot-avatar'; import type { HostLook } from '../design/host-look'; @@ -23,6 +24,9 @@ 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 { showActionMenu } from '../primitives/app-dialog'; const AVATAR = 48; const RING = 6; @@ -36,10 +40,12 @@ export function ConversationRow({ actions = [], onAction, look, + previewHref, }: { row: Conversation; now: Date; look?: HostLook | undefined; + previewHref?: Href | undefined; onPress?: () => void; actions?: readonly ConversationAction[]; onAction?: (key: ConversationActionKey) => void; @@ -59,32 +65,70 @@ 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) => ({ + label: action.label, + icon: + action.key === 'section' + ? 'folder' + : action.key === 'read' + ? 'check' + : action.key === 'pin' || action.key === 'unpin' + ? 'pin' + : 'mute', + run: () => act(action.key), + })); + const frame = ( + 0 && !row.muted} + badge={badge} + onPress={previewHref ? undefined : onPress} + avatar={ + row.kind === 'bot' ? ( + + + + ) : ( + + ) + } + /> + ); return ( - 0 && !row.muted} - badge={badge} - onPress={onPress} - avatar={ - row.kind === 'bot' ? ( - - - - ) : ( - - ) - } - /> + + + {previewHref ? {frame} : frame} + + {onAction && actions.length > 0 ? ( + + showActionMenu( + title, + actions.map((action) => ({ text: action.label, onPress: () => act(action.key) })), + ) + } + /> + ) : null} + ); } @@ -128,7 +172,9 @@ export function ConversationRowFrame({ onAction, testID, look, + ref, }: { + ref?: Ref; look?: HostLook | undefined; actions?: readonly ConversationAction[]; onAction?: (key: ConversationActionKey) => void; @@ -148,15 +194,17 @@ export function ConversationRowFrame({ const theme = useDesignTheme(); return ( ({ name: action.key, label: action.label }))} - onAccessibilityAction={(event) => - onAction?.(event.nativeEvent.actionName as ConversationActionKey) - } + onAccessibilityAction={(event) => { + const action = actions.find((item) => item.key === event.nativeEvent.actionName); + if (action) onAction?.(action.key); + }} style={({ pressed }) => ({ flexDirection: 'row', alignItems: 'center', diff --git a/apps/mobile/src/ui/chat/conversation-section-sheet.tsx b/apps/mobile/src/ui/chat/conversation-section-sheet.tsx new file mode 100644 index 0000000000..3d151693a4 --- /dev/null +++ b/apps/mobile/src/ui/chat/conversation-section-sheet.tsx @@ -0,0 +1,190 @@ +import { useRef, useState, useSyncExternalStore } from 'react'; +import { FlatList, View, type ViewInstance } from 'react-native'; + +import type { HostClient } from '../../host/client'; +import { useHostSnapshot } from '../../host/host-session'; +import { space } from '../design/tokens'; +import { Button } from '../primitives/button'; +import { DrawerHeader } from '../primitives/drawer-header'; +import { KeyboardFormScroll } from '../primitives/keyboard-form'; +import { SettingsRow } from '../primitives/settings-row'; +import { Text } from '../primitives/text'; +import { TextField } from '../primitives/text-field'; +import { t } from '../strings'; +import { useMounted } from '../mounted'; +import { createSectionFlow } from './conversation-sections'; + +export function ConversationSectionSheet({ + client, + channelId, + isCurrent, + onClose, +}: { + client: HostClient | undefined; + channelId: string; + isCurrent(): boolean; + onClose(): void; +}) { + const snapshot = useHostSnapshot(client); + const [creating, setCreating] = useState(false); + const [name, setName] = useState(''); + const [loadFailed, setLoadFailed] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const refreshingRef = useRef(false); + const mounted = useRef(true); + const mount = useMounted(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const [flow] = useState(() => + createSectionFlow( + client ?? { + call: async () => { + throw Error('missing Host'); + }, + refresh: async () => {}, + }, + channelId, + () => mounted.current && isCurrent(), + ), + ); + const state = useSyncExternalStore(flow.subscribe, flow.getSnapshot); + const current = isCurrent(); + const sections = current ? snapshot.roster?.arrangement?.sections : undefined; + const close = () => { + if (!state.busy) onClose(); + }; + const run = async (work: () => Promise) => { + await work(); + if (!mounted.current) return; + const next = flow.getSnapshot(); + if (next.complete && !next.busy && !next.error && isCurrent()) onClose(); + }; + const error = + !current || state.error === 'changed' + ? t.conversationSection.changed + : state.error === 'create' + ? t.conversationSection.createFailed + : state.error === 'assign' + ? t.conversationSection.assignFailed + : state.error === 'refresh' + ? t.conversationSection.refreshFailed + : loadFailed + ? t.conversationSection.loadFailed + : undefined; + const notice = error ? ( + + {error} + + ) : null; + const reload = async () => { + if (!isCurrent() || !client || refreshingRef.current) return; + refreshingRef.current = true; + setRefreshing(true); + setLoadFailed(false); + try { + await client.refresh(); + } catch { + if (mounted.current) setLoadFailed(true); + } finally { + refreshingRef.current = false; + if (mounted.current) setRefreshing(false); + } + }; + return ( + + + + + {creating ? ( + + {notice} + {state.selected ? ( + {state.selected.name} + ) : ( + { + if (!state.busy && current && name.trim()) void run(() => flow.create(name)); + }} + /> + )} + + {open ? props.renderRightActions() : null} + + ); + }, + }; +}); +vi.mock('../src/ui/primitives/app-dialog', () => ({ + showActionMenu: (_title: string, actions: { text: string; onPress?: () => unknown }[]) => + menus.push(actions), + showAppDialog: vi.fn(), })); +vi.mock('../src/ui/shell/conversation-peek', async () => { + const { Platform } = await import('react-native'); + return { + get conversationPeek() { + return Platform.OS === 'ios'; + }, + ConversationPeek: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + }; +}); const roster: RosterView = { bots: [ { @@ -105,6 +165,7 @@ vi.mock('react-native-svg', () => ({ ), Path: () => null, + Circle: () => null, })); vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), @@ -124,14 +185,17 @@ vi.mock('expo-router', () => { vi.mock('../src/host/host-session', () => ({ useHostSession: () => ({ saved: hostState.removed ? null : { hostId: 'h1', hostName: 'studio' }, - client: undefined, + client: hostState.actions ? { call, refresh } : undefined, inbox: undefined, }), useHostSnapshot: () => ({ connection: { status: hostState.connecting ? 'connecting' : 'connected', route: '' }, roster: hostState.connecting ? undefined : roster, }), - useInbox: () => ({ status: 'loading' }), + useInbox: () => + hostState.unread + ? { status: 'ready', sections: { action: [] }, unread: { 'dm-ada': 2 } } + : { status: 'loading' }, useQuietChannels: () => [], refreshQuietChannels: () => {}, })); @@ -184,6 +248,15 @@ afterEach(() => { hostState.removed = false; hostState.color = undefined; hostState.fontScale = 1; + hostState.actions = false; + hostState.unread = false; + delete roster.bots[0]!.latestMessageId; + delete roster.arrangement; + call.mockClear(); + refresh.mockClear(); + menus.length = 0; + swipeConfigs.length = 0; + Platform.OS = 'web'; conversationFilter.set('all'); cloudState.rows = []; cloudState.ready = true; @@ -215,6 +288,11 @@ async function type(view: HTMLElement, text: string): Promise { const rows = (view: HTMLElement) => [...view.querySelectorAll('button')] + .filter( + (node) => + node.dataset.testid !== 'action-menu-trigger' && + node.getAttribute('aria-label') !== 'Swipe row', + ) .filter((node) => node.dataset.testid !== 'chats-view-button') .filter((node) => !node.closest('[hidden]') && !node.dataset.testid?.startsWith('pager-')) .filter( @@ -527,3 +605,129 @@ describe('Selected current Host accent', () => { }, ); }); + +describe('Chats row actions share the Host path', () => { + it('keeps mark-read in the menu while revealing only the three requested actions', async () => { + hostState.actions = true; + hostState.unread = true; + roster.bots[0]!.latestMessageId = 'message-1'; + const view = await home(); + await act(async () => + view.querySelector('[aria-label="Swipe row"]')!.click(), + ); + expect(view.querySelectorAll('[data-testid^="conversation-swipe-"]')).toHaveLength(3); + expect(view.querySelector('[data-testid="conversation-swipe-read"]')).toBeNull(); + await act(async () => + view + .querySelector(`[aria-label="${t.conversationActions('Ada')}"]`)! + .click(), + ); + await act(async () => + menus.at(-1)!.find((action) => action.text === t.rowActions.read)!.onPress!(), + ); + expect(call).toHaveBeenLastCalledWith('channelMarkRead', { + channelId: 'dm-ada', + messageId: 'message-1', + }); + }); + it.each(['ios', 'android'] as const)( + 'runs pin and mute from swipe and menu on %s', + async (platform) => { + Platform.OS = platform; + hostState.actions = true; + const view = await home(); + for (const key of ['pin', 'mute'] as const) { + await act(async () => + view.querySelector('[aria-label="Swipe row"]')!.click(), + ); + await act(async () => + view + .querySelector(`[data-testid="conversation-swipe-${key}"]`)! + .click(), + ); + const swipeCall = call.mock.calls.at(-1); + await act(async () => + view + .querySelector(`[aria-label="${t.conversationActions('Ada')}"]`)! + .click(), + ); + await act(async () => + menus.at(-1)!.find((action) => action.text === t.rowActions[key])!.onPress!(), + ); + expect(call.mock.calls.at(-1)).toEqual(swipeCall); + expect(swipeCall).toEqual( + key === 'pin' + ? ['rosterBatch', { action: 'pin', channelIds: ['dm-ada'] }] + : ['channelMuteSet', { channelId: 'dm-ada', muted: true }], + ); + } + expect(refresh).toHaveBeenCalledTimes(4); + expect(view.querySelector('[data-testid="native-preview"]') !== null).toBe( + platform === 'ios', + ); + expect(swipeConfigs[0]?.hitSlop).toEqual( + platform === 'ios' ? { left: -EDGE_WIDTH } : undefined, + ); + }, + ); + + it('opens the same Host-bound section chooser from the menu and swipe', async () => { + hostState.actions = true; + const view = await home(); + await act(async () => + view.querySelector('[aria-label="Swipe row"]')!.click(), + ); + await act(async () => + view.querySelector('[data-testid="conversation-swipe-section"]')!.click(), + ); + expect(push).toHaveBeenLastCalledWith({ + pathname: '/conversation-section', + params: { hostId: 'h1', channelId: 'dm-ada' }, + }); + await act(async () => + view + .querySelector(`[aria-label="${t.conversationActions('Ada')}"]`)! + .click(), + ); + await act(async () => + menus.at(-1)!.find((action) => action.text === t.rowActions.section)!.onPress!(), + ); + expect(push.mock.calls[1]).toEqual(push.mock.calls[0]); + expect(call).not.toHaveBeenCalled(); + }); + + it('uses the same inverse operations for pinned and muted conversations', async () => { + hostState.actions = true; + roster.arrangement = { + pins: ['dm-ada'], + muted: ['dm-ada'], + hidden: [], + sections: [], + sectionOrder: [], + }; + const view = await home(); + for (const key of ['unpin', 'unmute'] as const) { + await act(async () => + view.querySelector('[aria-label="Swipe row"]')!.click(), + ); + await act(async () => + view.querySelector(`[data-testid="conversation-swipe-${key}"]`)!.click(), + ); + const fromSwipe = call.mock.calls.at(-1); + await act(async () => + view + .querySelector(`[aria-label="${t.conversationActions('Ada')}"]`)! + .click(), + ); + await act(async () => + menus.at(-1)!.find((action) => action.text === t.rowActions[key])!.onPress!(), + ); + expect(call.mock.calls.at(-1)).toEqual(fromSwipe); + expect(fromSwipe).toEqual( + key === 'unpin' + ? ['rosterBatch', { action: 'unpin', channelIds: ['dm-ada'] }] + : ['channelMuteSet', { channelId: 'dm-ada', muted: false }], + ); + } + }); +}); diff --git a/changes/2519-chat-row-swipe.md b/changes/2519-chat-row-swipe.md new file mode 100644 index 0000000000..a2a568332a --- /dev/null +++ b/changes/2519-chat-row-swipe.md @@ -0,0 +1,11 @@ +--- +section: Added +--- + +## en + +- Phone Chats rows now reveal Pin, Add to section and Mute by swiping, with the same actions in an accessible menu. Choose or create a section on the conversation's Remote Host; retrying a failed assignment reuses the created section, and switching Hosts refuses stale actions. iOS keeps its left-edge drawer gesture and chat preview ([#2519](https://github.com/BotHarness/DeepSeekBot/issues/2519)). + +## zh + +- 手机聊天行支持滑动显示置顶、添加到分区和静音,也可通过无障碍菜单操作。在会话所属的远程 Host 选择或新建分区;添加失败后重试会复用已创建的分区,切换 Host 后拒绝旧操作。iOS 保留左边缘抽屉手势和聊天预览([#2519](https://github.com/BotHarness/DeepSeekBot/issues/2519))。 diff --git a/docs/architecture/botharness-architecture.md b/docs/architecture/botharness-architecture.md index 6f46e2c7ba..b6ea1a0d94 100644 --- a/docs/architecture/botharness-architecture.md +++ b/docs/architecture/botharness-architecture.md @@ -1074,6 +1074,10 @@ The mobile app uses its existing Expo Router tab and root Stack owners for an ad ### Mobile presentation (#2028) +Chats row swipe, native menu and accessibility actions share the application-defined Host operation path (#2519). The section form sheet captures the conversation's Remote Host client and reads its existing roster snapshot/subscription. Creating a section calls `sectionCreate`, then assigns the conversation through `channelAssign` with the returned ID; refresh returns to the same roster authority. Only transient submission/selection state lives in the sheet. A confirmed section ID is retained for assignment retry, while a successful assignment followed by a failed refresh retries only the read. Host/client changes refuse further continuations; there is no device-local section store or cross-Host section namespace. Native gesture, keyboard and accessibility qualification remains separate from source coverage. + +聊天行滑动、原生菜单和无障碍操作复用同一 Host 操作路径(#2519)。分区表单绑定会话所属的远程 Host client,通过既有 roster 快照及订阅读取分区;`sectionCreate` 返回 ID 后,`channelAssign` 将会话加入该分区。表单只保留临时选择和提交状态:创建成功但添加失败时复用原 ID 重试,添加成功但刷新失败时只重试读取。Host/client 切换后拒绝后续操作,不新增设备本地分区存储或跨 Host 分区空间。原生手势、键盘与无障碍验收独立于源代码测试。 + Android chat Markdown has a separate, disposable geometry module. `MarkdownViewportContext` supplies the existing chat viewport and scroll values; the UI thread reports coarse visible-window changes, while the renderer retains measured block heights and minimum bubble width. Offscreen text becomes equal-height spacing and restores through the same Markdown path. Width/font changes discard geometry, native media remains mounted, and screen readers use the full document. This module owns no message data, persistence, playback cursor or Host command. The block parser also retains its disposable lexer result with exact source provenance. Android rendering and window-support checks reuse that result; repaired tails and source-only blocks reparse, while document-wide references retain their complete parse context. Append-only boundary lookback preserves unchanged block identities. The [single-paragraph Arena](../research/2183-single-paragraph-arena.md) measures the CPU benefit separately from native drawing limits; this cache owns no conversation state. diff --git a/scripts/verification-inventory.json b/scripts/verification-inventory.json index 0545e03c5b..47faeb22b6 100644 --- a/scripts/verification-inventory.json +++ b/scripts/verification-inventory.json @@ -3,6 +3,14 @@ "auditedBase": "1d1f8ac0cfeb26a8f3bf3a04550e6b92cee52bd9", "boundary": "All scripts/e2e-*.mjs files and all current feature-map documents, augmented by implemented Client/CLI/mobile source entry points. Behavior rows group related acceptance paths; they are not an exhaustive product behavior denominator. Planned unmerged #1954 work and packaged/restart identity promises are excluded.", "behaviors": [ + { + "id": "phone.chat-row-actions", + "label": "Swipe or open the menu to pin, mute, assign or create a Host section", + "source": "apps/mobile/src/ui/chat/conversation-section-sheet.tsx", + "guide": ".agents/skills/verify-deepseekbot/features/phone-chat-row-actions.md", + "task": 2519, + "gap": "Native iPhone and Android gestures, menus, keyboard, screen readers and light/dark large-text qualification await the shared-device queues." + }, { "id": "marketplace.browse", "label": "Browse flat Bot cards with banners, topic filters and remembered sorting",