diff --git a/.github/pr-screenshots/2179-unified-conversations/README.md b/.github/pr-screenshots/2179-unified-conversations/README.md new file mode 100644 index 0000000000..1217b54e47 --- /dev/null +++ b/.github/pr-screenshots/2179-unified-conversations/README.md @@ -0,0 +1,33 @@ +# Slice #2179 — UAT native evidence + +- PR: https://github.com/BotHarness/DeepSeekBot/pull/2185 +- Source / integrated QA head: `d9155abe412dbdf4445394744f54d0e1f8743c4e`. +- Before source: `4c6acb7eae2b0c72cf036fa6934b893054a0a5aa`. +- Retained SDK 58 native build source: `494a20f1e`; JS-only reload, no rebuild. +- Environment: designated iOS Simulator, 402 × 874 captures; TOB UAT only. +- CI: https://github.com/BotHarness/DeepSeekBot/actions/runs/39125360611 — success. + +## Observed results + +| Check | Result | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Mixed Computer and Cloud rows, source icons and filters | Passed; organization filter also survived restart | +| Computer send | One canonical Host message, Human author; explicit keyboard Send passed after moving the development tool overlay | +| Computer receive | Bot channel_send CLI message visibly received through the normal Host conversation | +| Cloud send | Canonical sequences 19 and 20, expected cloud user and organization; no Computer context switch | +| Computer route disconnected | Cloud sequence 20 committed while Host client reported unreachable; Computer Send unavailable; cached history and draft retained | +| Cold restart | Cloud identity/directory restored; Computer draft restored after reconnect | +| No automatic resend | Unsent DRAFT-KEEP marker absent from Host canonical history after restart/reconnect | +| Display checks | Default/intermediate/maximum text, light/dark captured; scrolling retained. Maximum text truncates names substantially; screenshots document that limitation | +| Cleanup | Own WorkOS session revoked, latest refresh rejected, prior sessions preserved; new Host device revoked/unpaired; original signed-out configuration, draft/filter files, shared checkout, light/large and locale restored | + +## Recordings + +- `offline-cloud-and-draft-4x.mp4`: actual continuous test recording accelerated 4×; preserves the offline Computer draft → Cloud send/readback UI sequence. Resolution reduced for review; no synthesized frames or UI. +- `restart-draft.mp4`: actual recording after a cold restart and explicit reconnect; opening the Computer channel restores the unsent draft. Unchanged idle time retained. + +The first failed cold-start run was preserved privately: an empty organization sentinel made the vault invalid and blocked the list. The source head above fixes it, with failing-then-passing regressions and fresh CI. The failed fixture was normalized only after preserving its private backup; all successful observations above used the corrected source. Credentials were never recorded or published. + +Before/after list captures use the same fixture accounts and viewport. Computer-channel after captures include new uniquely marked test messages; those demonstrate source-correct delivery, not a pixel-identical history baseline. + +Cloud latest-message summaries/read counts/pins remain #2181. Cloud uses the existing channel screen. Cold-starting with an offline Computer does not repopulate its roster until reconnect; retained text drafts are restored when the conversation can be opened. Android, physical device, VoiceOver and Human QA remain unverified. No Prod change, merge, or later-slice implementation. diff --git a/.github/pr-screenshots/2179-unified-conversations/after-computer-channel.png b/.github/pr-screenshots/2179-unified-conversations/after-computer-channel.png new file mode 100644 index 0000000000..41bdf96fd0 Binary files /dev/null and b/.github/pr-screenshots/2179-unified-conversations/after-computer-channel.png differ diff --git a/.github/pr-screenshots/2179-unified-conversations/after-list-fixed-light.png b/.github/pr-screenshots/2179-unified-conversations/after-list-fixed-light.png new file mode 100644 index 0000000000..73d5725a42 Binary files /dev/null and b/.github/pr-screenshots/2179-unified-conversations/after-list-fixed-light.png differ diff --git a/.github/pr-screenshots/2179-unified-conversations/after-list-intermediate-dark.png b/.github/pr-screenshots/2179-unified-conversations/after-list-intermediate-dark.png new file mode 100644 index 0000000000..67ab65ab3a Binary files /dev/null and b/.github/pr-screenshots/2179-unified-conversations/after-list-intermediate-dark.png differ diff --git a/.github/pr-screenshots/2179-unified-conversations/after-list-intermediate-light.png b/.github/pr-screenshots/2179-unified-conversations/after-list-intermediate-light.png new file mode 100644 index 0000000000..eff4a02fdf Binary files /dev/null and b/.github/pr-screenshots/2179-unified-conversations/after-list-intermediate-light.png differ diff --git a/.github/pr-screenshots/2179-unified-conversations/after-list-max-dark.png b/.github/pr-screenshots/2179-unified-conversations/after-list-max-dark.png new file mode 100644 index 0000000000..381d6e46ad Binary files /dev/null and b/.github/pr-screenshots/2179-unified-conversations/after-list-max-dark.png differ diff --git a/.github/pr-screenshots/2179-unified-conversations/after-list-max-light.png b/.github/pr-screenshots/2179-unified-conversations/after-list-max-light.png new file mode 100644 index 0000000000..d76d47d011 Binary files /dev/null and b/.github/pr-screenshots/2179-unified-conversations/after-list-max-light.png differ diff --git a/.github/pr-screenshots/2179-unified-conversations/before-computer-channel.png b/.github/pr-screenshots/2179-unified-conversations/before-computer-channel.png new file mode 100644 index 0000000000..0043458a9a Binary files /dev/null and b/.github/pr-screenshots/2179-unified-conversations/before-computer-channel.png differ diff --git a/.github/pr-screenshots/2179-unified-conversations/before-list-light.png b/.github/pr-screenshots/2179-unified-conversations/before-list-light.png new file mode 100644 index 0000000000..3dec4fe30e Binary files /dev/null and b/.github/pr-screenshots/2179-unified-conversations/before-list-light.png differ diff --git a/.github/pr-screenshots/2179-unified-conversations/cloud-offline-sent.png b/.github/pr-screenshots/2179-unified-conversations/cloud-offline-sent.png new file mode 100644 index 0000000000..3e662162f4 Binary files /dev/null and b/.github/pr-screenshots/2179-unified-conversations/cloud-offline-sent.png differ diff --git a/.github/pr-screenshots/2179-unified-conversations/computer-offline-draft.png b/.github/pr-screenshots/2179-unified-conversations/computer-offline-draft.png new file mode 100644 index 0000000000..728afaf3fc Binary files /dev/null and b/.github/pr-screenshots/2179-unified-conversations/computer-offline-draft.png differ diff --git a/.github/pr-screenshots/2179-unified-conversations/computer-received-keyboard.png b/.github/pr-screenshots/2179-unified-conversations/computer-received-keyboard.png new file mode 100644 index 0000000000..01624c8e72 Binary files /dev/null and b/.github/pr-screenshots/2179-unified-conversations/computer-received-keyboard.png differ diff --git a/.github/pr-screenshots/2179-unified-conversations/final-list-dark.png b/.github/pr-screenshots/2179-unified-conversations/final-list-dark.png new file mode 100644 index 0000000000..1af1a732b0 Binary files /dev/null and b/.github/pr-screenshots/2179-unified-conversations/final-list-dark.png differ diff --git a/.github/pr-screenshots/2179-unified-conversations/final-list-light.png b/.github/pr-screenshots/2179-unified-conversations/final-list-light.png new file mode 100644 index 0000000000..e3ce6fb5a7 Binary files /dev/null and b/.github/pr-screenshots/2179-unified-conversations/final-list-light.png differ diff --git a/.github/pr-screenshots/2179-unified-conversations/offline-cloud-and-draft-4x.mp4 b/.github/pr-screenshots/2179-unified-conversations/offline-cloud-and-draft-4x.mp4 new file mode 100644 index 0000000000..3188ade80c Binary files /dev/null and b/.github/pr-screenshots/2179-unified-conversations/offline-cloud-and-draft-4x.mp4 differ diff --git a/.github/pr-screenshots/2179-unified-conversations/restart-draft.mp4 b/.github/pr-screenshots/2179-unified-conversations/restart-draft.mp4 new file mode 100644 index 0000000000..ee9388890b Binary files /dev/null and b/.github/pr-screenshots/2179-unified-conversations/restart-draft.mp4 differ diff --git a/.github/pr-screenshots/2179-unified-conversations/restart-restored-draft.png b/.github/pr-screenshots/2179-unified-conversations/restart-restored-draft.png new file mode 100644 index 0000000000..ecde43f309 Binary files /dev/null and b/.github/pr-screenshots/2179-unified-conversations/restart-restored-draft.png differ diff --git a/apps/mobile/app/(tabs)/(home)/index.tsx b/apps/mobile/app/(tabs)/(home)/index.tsx index 95313a2a48..8c88b476ae 100644 --- a/apps/mobile/app/(tabs)/(home)/index.tsx +++ b/apps/mobile/app/(tabs)/(home)/index.tsx @@ -1,25 +1,24 @@ import { renderKeyboardFormScroll } from '../../../src/ui/primitives/keyboard-form'; -import { Redirect, Stack, useIsFocused, useRouter } from 'expo-router'; -import { useMemo, useState } from 'react'; -import { ActivityIndicator, RefreshControl, View } from 'react-native'; +import { Redirect, Stack, useFocusEffect, useIsFocused, useRouter } from 'expo-router'; +import { useCallback, useMemo, useState } from 'react'; +import { ActivityIndicator, RefreshControl, ScrollView, View } from 'react-native'; import Animated from 'react-native-reanimated'; - import { fullClientParams } from '../../../src/host/full-client'; import { useHostSession, useHostSnapshot, useInbox } from '../../../src/host/host-session'; import { useHostLook } from '../../../src/host/native-host-look'; -import { shownHostName } from '../../../src/ui/design/host-look'; -import { sectionCollapse, useCollapsedSections } from '../../../src/host/native-section-collapse'; +import { cloudClient, useCloudSnapshot } from '../../../src/cloud/native'; +import { cloudConversations, useCloudConversations } from '../../../src/cloud/native-conversations'; +import { cloudViewScope } from '../../../src/cloud/workspace-directory'; +import { cloudError } from '../../../src/cloud/client'; +import type { CloudConversation } from '../../../src/cloud/conversations'; import { ConversationRow } from '../../../src/ui/chat/conversation-row'; -import { - chatList, - PINNED_SECTION, - searchConversations, - type ChatListItem, -} from '../../../src/ui/chat/conversations'; -import { SectionHeader } from '../../../src/ui/chat/section-header'; +import { conversations, searchKey, type Conversation } from '../../../src/ui/chat/conversations'; +import { CloudConversationRow } from '../../../src/ui/chat/cloud-conversation-row'; +import { useConversationFilter } from '../../../src/host/native-conversation-filter'; import { ConnectionBanner } from '../../../src/ui/connection-banner'; import { space } from '../../../src/ui/design/tokens'; import { SearchField } from '../../../src/ui/primitives/search-field'; +import { Chip } from '../../../src/ui/primitives/chip'; import { Text } from '../../../src/ui/primitives/text'; import { CreateButton, type CreateTarget } from '../../../src/ui/shell/create-button'; import { HomeHeader } from '../../../src/ui/shell/home-header'; @@ -29,10 +28,10 @@ import { useFloatingTabInset } from '../../../src/ui/shell/floating-tab-inset'; import { openConversation } from '../../../src/ui/shell/open-conversation'; import { useChromeScroll } from '../../../src/ui/shell/scroll-chrome'; import { t } from '../../../src/ui/strings'; -import type { HumanAttentionItem } from '../../../src/host/wire'; - -const NONE: HumanAttentionItem[] = []; +type Row = + | { key: string; type: 'computer'; conversation: Conversation; pinned: boolean; at?: string } + | { key: string; type: 'cloud'; conversation: CloudConversation; pinned: false; at?: string }; export default function HomeScreen() { const router = useRouter(); const focused = useIsFocused(); @@ -42,53 +41,147 @@ export default function HomeScreen() { const look = useHostLook(saved?.hostId); const snapshot = useHostSnapshot(client, focused); const list = useInbox(inbox, focused); + const cloud = useCloudSnapshot(); + const directory = useCloudConversations(); const [query, setQuery] = useState(''); + const [filter, setFilter] = useConversationFilter(); const [refreshing, setRefreshing] = useState(false); - const action = list.status === 'ready' ? list.sections.action : NONE; - const unread = list.status === 'ready' ? list.unread : undefined; - const hostId = saved?.hostId; - const collapsed = useCollapsedSections(hostId); - const searching = query.trim() !== ''; - const rows = useMemo( - () => - searching - ? searchConversations(snapshot.roster, action, query, unread).map((row): ChatListItem => ({ - kind: 'row', - key: row.key, - row, - })) - : chatList(snapshot.roster, action, collapsed, unread), - [snapshot.roster, action, collapsed, query, searching, unread], + const [opening, setOpening] = useState(false); + const [error, setError] = useState(); + const enrollment = JSON.stringify(cloud.rows.map(cloudViewScope)); + useFocusEffect( + useCallback(() => { + if (cloud.ready) void cloudConversations.refresh(); + }, [cloud.ready, enrollment]), ); - const now = new Date(); - - if (saved === undefined) + const filters = useMemo(() => { + const choices = new Map([['all', t.allConversations]]); + if (saved) choices.set(JSON.stringify(['computer', saved.hostId]), saved.hostName); + for (const row of directory.rows) + choices.set(JSON.stringify([row.scope, row.organizationId]), row.organizationName); + return [...choices].map(([key, label]) => ({ key, label })); + }, [saved, directory.rows]); + const effectiveFilter = filters.some((f) => f.key === filter) ? filter : 'all'; + const rows = useMemo(() => { + const pins = new Set(snapshot.roster?.arrangement?.pins); + const hostRows: Row[] = + effectiveFilter === 'all' || effectiveFilter === JSON.stringify(['computer', saved?.hostId]) + ? conversations( + snapshot.roster, + list.status === 'ready' ? list.sections.action : [], + list.status === 'ready' ? list.unread : {}, + ).map((conversation) => ({ + key: JSON.stringify(['computer', saved?.hostId, conversation.key]), + type: 'computer', + conversation, + pinned: + pins.has( + conversation.kind === 'bot' ? conversation.bot.slug : conversation.channel.id, + ) || + pins.has( + conversation.kind === 'bot' + ? (conversation.bot.dmChannelId ?? '') + : conversation.channel.id, + ), + ...(conversation.at ? { at: conversation.at } : {}), + })) + : []; + const cloudRows: Row[] = directory.rows + .filter( + (r) => + effectiveFilter === 'all' || + effectiveFilter === JSON.stringify([r.scope, r.organizationId]), + ) + .map((conversation) => ({ + key: conversation.key, + type: 'cloud', + conversation, + pinned: false, + })); + const needle = searchKey(query); + const title = (r: Row) => + searchKey( + r.type === 'cloud' + ? r.conversation.channel.name + : r.conversation.kind === 'bot' + ? r.conversation.bot.displayName + : r.conversation.channel.name, + ); + return [...hostRows, ...cloudRows] + .filter((r) => !needle || title(r).includes(needle)) + .sort( + (a, b) => + (needle + ? Number(title(b).startsWith(needle)) - Number(title(a).startsWith(needle)) + : 0) || + Number(b.pinned) - Number(a.pinned) || + (Date.parse(b.at ?? '') || 0) - (Date.parse(a.at ?? '') || 0) || + a.key.localeCompare(b.key), + ); + }, [snapshot.roster, list, directory.rows, saved?.hostId, effectiveFilter, query]); + if (saved === undefined || (!cloud.ready && !cloud.error)) return ( - + ); - if (saved === null) return ; - + if (saved === null && cloud.rows.length === 0) return ; const create = (target: CreateTarget) => router.push({ pathname: '/full-client', params: fullClientParams(target) }); - const refresh = async () => { setRefreshing(true); - await Promise.all([client?.refresh(), inbox?.refresh()]).catch(() => undefined); + await Promise.all([client?.refresh(), inbox?.refresh(), cloudConversations.refresh()]).catch( + () => undefined, + ); setRefreshing(false); }; - + const openCloud = async (target: CloudConversation) => { + if (opening || cloud.busy || directory.busy) return; + setOpening(true); + setError(undefined); + try { + let current = cloudClient + .getSnapshot() + .rows.find((r) => r.config.instanceId === target.instanceId); + if (cloudViewScope(current) !== target.scope) throw Error('changed source'); + if (!current?.identity) await cloudClient.whoami(target.instanceId); + current = cloudClient + .getSnapshot() + .rows.find((r) => r.config.instanceId === target.instanceId); + if (!target.guest && current?.organization?.organizationId !== target.organizationId) + await cloudClient.useOrganization(target.instanceId, target.organizationId); + if ( + cloudViewScope( + cloudClient.getSnapshot().rows.find((r) => r.config.instanceId === target.instanceId), + ) !== target.scope + ) + throw Error('changed source'); + router.push({ + pathname: '/cloud-channels', + params: { + instanceId: target.instanceId, + organizationId: target.organizationId, + channelId: target.channel.channelId, + guest: String(target.guest), + accountScope: target.scope, + }, + }); + } catch (e) { + setError(cloudError(e)); + } finally { + setOpening(false); + } + }; return ( <> - + router.push('/settings')} />} > - renderScrollComponent={renderKeyboardFormScroll} onScroll={onScroll} scrollEventThrottle={1} @@ -96,55 +189,80 @@ export default function HomeScreen() { keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" data={rows} - contentInsetAdjustmentBehavior="automatic" - keyExtractor={(row) => row.key} - refreshControl={} + keyExtractor={(r) => r.key} + refreshControl={ + void refresh()} + /> + } ListHeaderComponent={ <> - client?.retryNow()} - onPairAgain={() => router.push('/pair')} - /> + {saved && ( + client?.retryNow()} + onPairAgain={() => router.push('/pair')} + /> + )} } + trailing={saved ? : undefined} /> + + {filters.map((f) => ( + setFilter(f.key)} + /> + ))} + + {(directory.errors.length > 0 || cloud.error || error) && ( + + {t.cloudListUnavailable} + {error ? ` (${error})` : ''} + + )} } ListEmptyComponent={ - snapshot.roster === undefined ? undefined : ( - - - {searching ? t.searchNoMatch(query.trim()) : t.noBots} - - + directory.busy || + (saved && !snapshot.roster && snapshot.connection.status === 'connecting') ? ( + + ) : ( + + {query ? t.searchNoMatch(query.trim()) : t.cloudChannels.empty} + ) } - renderItem={({ item }) => { - if (item.kind === 'section') - return ( - sectionCollapse.toggle(saved.hostId, item.id)} - /> - ); - const row = item.row; - return ( + renderItem={({ item }) => + item.type === 'computer' ? ( openConversation(router, row)} + row={item.conversation} + now={new Date()} + onPress={() => openConversation(router, item.conversation, saved?.hostId)} + /> + ) : ( + void openCloud(item.conversation)} /> - ); - }} + ) + } /> diff --git a/apps/mobile/app/channel/[id].tsx b/apps/mobile/app/channel/[id].tsx index 29453e0d0a..07123d9abe 100644 --- a/apps/mobile/app/channel/[id].tsx +++ b/apps/mobile/app/channel/[id].tsx @@ -3,13 +3,15 @@ import { useLocalSearchParams } from 'expo-router'; import { ConversationScreen } from '../../src/ui/chat/conversation-screen'; export default function ChannelScreen() { - const { id, focus, focusWindow } = useLocalSearchParams<{ + const { id, focus, focusWindow, hostId } = useLocalSearchParams<{ id: string; + hostId?: string; focus?: string; focusWindow?: string; }>(); return ( (); - return ; + const { slug, focus, hostId } = useLocalSearchParams<{ + slug: string; + hostId?: string; + focus?: string; + }>(); + return ; } diff --git a/apps/mobile/src/cloud/README.md b/apps/mobile/src/cloud/README.md index a6333cfd3b..a217fa6858 100644 --- a/apps/mobile/src/cloud/README.md +++ b/apps/mobile/src/cloud/README.md @@ -15,3 +15,13 @@ Channel HTTP operations use the same verified selected workspace and public API. Workspace invitations use the same CloudClient: owners send/list/revoke; the recipient inbox and accept action work before organization selection. Issuance is persisted with user/org/email/operation before I/O and survives restart. Public invitation IDs are not login credentials; provider tokens/acceptance URLs are stripped and never stored. Accept reads server membership, then the screen explicitly selects that organization through the existing refresh path. Member status does not grant owner controls. UAT/native evidence is tracked in #2145. Direct channel guests (#2148) use separate `/api/channel-invitations` and `/api/guest-channels` paths. Acceptance does not refresh into the host organization. Owner channel invitations and guest sends persist channel-bound pending operations; uncertain writes reuse the same key after restart. Channel membership remains server-owned. The guest list cursor is an invitation ID, while ordinary channel lists use channel IDs. An empty first guest history page can advance to its accepted sequence floor. This source implementation still needs actual UAT/native evidence. + +## Unified conversation entry (#2179) + +Mobile Home combines the selected paired Computer's roster with authorized cloud Channel directories. Directory reads temporarily borrow an organization-scoped token under the existing cloud-session lock and restore the previous scope; they do not select a workspace in the UI. Membership and guest pages retain their existing server checks. Cloud account/config changes invalidate old directory responses. No Host credential becomes a cloud login. + +The first slice still uses one active Computer. Two active Computer lifecycles, browser Web and DSH Desktop are #2180. Cloud read/pin authority and server-provided recent-message summaries are #2181; until those summaries are supplied, an unopened cloud row shows “Open to load messages” and does not invent activity timestamps or unread counts. This is not full spec #2178 completion. + +Conversation routes carry the source Host ID or cloud account/config scope. A source change refuses an old route instead of sending through the newly active source. Unsent text is client-local and source-namespaced; it is never queued for automatic delivery. Host conversation caches are now Host-namespaced, so old unscoped cache files are not adopted as trusted history. Cloud message cache is account/config/org/channel scoped. A cached view is not proof of delivery or current permission. + +Validation: source tests, live API/CLI checks and assigned Simulator UAT are separate gates. No new Host route/tool is introduced; existing Host send/Channel commands and TOB message commands remain the business verification paths. diff --git a/apps/mobile/src/cloud/client.ts b/apps/mobile/src/cloud/client.ts index a71e0838e4..c5edab8b81 100644 --- a/apps/mobile/src/cloud/client.ts +++ b/apps/mobile/src/cloud/client.ts @@ -78,6 +78,14 @@ const fail = (code: string): never => { throw new ClientError(code); }; export const cloudError = (e: unknown) => (e instanceof ClientError ? e.code : 'CLOUD_UNAVAILABLE'); +export const canRetainCloudCache = (error: unknown) => + [ + 'NETWORK_UNAVAILABLE', + 'CLOUD_UNAVAILABLE', + 'INSTANCE_UNAVAILABLE', + 'SESSION_BUSY', + 'AUTH_RETRY_REQUIRED', + ].includes(cloudError(error)); function record(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) return fail('INVALID_RESPONSE'); return value as Record; @@ -430,7 +438,11 @@ export class CloudClient { value: unknown, organizationId = entry.session?.organizationId, ) { - const session = { ...tokenPair(value), userId: entry.session?.userId, organizationId }; + const session = { + ...tokenPair(value), + userId: entry.session?.userId, + organizationId: organizationId || undefined, + }; await this.save({ ...entry, session }); return this.entry(entry.config.instanceId); } @@ -646,42 +658,75 @@ export class CloudClient { const entry = guest ? await this.current(this.entry(id)) : await this.channelEntry(id, organizationId); - if (guest && organizationId && !/^org_[A-Za-z0-9]+$/.test(organizationId)) - return fail('INVALID_ORGANIZATION'); - const query = new URLSearchParams(); - if (after) query.set('after', after); - if (guest && organizationId) query.set('organizationId', organizationId); - const value = await this.orgRequest( - entry, - (guest ? '' : '/' + organizationId + '/channels') + - (query.size ? '?' + query.toString() : ''), - {}, - guest ? '/api/guest-channels' : '/api/organizations', - ); - if ( - !Array.isArray(value.channels) || - value.channels.length > 50 || - (value.nextCursor !== null && - !(guest - ? typeof value.nextCursor === 'string' && /^ci_[a-f0-9]{64}$/.test(value.nextCursor) - : validChannelId(value.nextCursor))) - ) - return fail('INVALID_RESPONSE'); - return { - channels: value.channels.map((raw) => { - const channel = parseChannel(raw); - if ( - guest && - (!/^org_[A-Za-z0-9]+$/.test(channel.organizationId ?? '') || - record(raw).access !== 'guest' || - (organizationId && channel.organizationId !== organizationId)) - ) - return fail('INVALID_RESPONSE'); - return channel; - }), - nextCursor: value.nextCursor as string | null, - }; + return this.channelPage(entry, organizationId, after, guest); + }); + listConversationChannels = (id: string, organizationId: string, after?: string, guest = false) => + this.lock(async () => { + let entry = await this.current(this.entry(id)); + if (guest) return this.channelPage(entry, organizationId, after, true); + if (!/^org_[A-Za-z0-9]+$/.test(organizationId)) return fail('INVALID_ORGANIZATION'); + const previous = entry.session?.organizationId ?? ''; + if (previous === organizationId) return this.channelPage(entry, organizationId, after, false); + await this.orgRequest(entry, '/' + organizationId + '/access'); + entry = await this.rotate(entry, organizationId); + let result: Awaited>; + let restoreFailed = false; + try { + result = await this.channelPage(entry, organizationId, after, false); + } finally { + try { + await this.rotate(this.entry(id), previous); + } catch { + this.organizations.delete(id); + this.publish(); + restoreFailed = true; + } + } + if (restoreFailed) return fail('ORGANIZATION_RESTORE_REQUIRED'); + return result; }); + private async channelPage( + entry: Entry, + organizationId: string, + after: string | undefined, + guest: boolean, + ) { + if (guest && organizationId && !/^org_[A-Za-z0-9]+$/.test(organizationId)) + return fail('INVALID_ORGANIZATION'); + const query = new URLSearchParams(); + if (after) query.set('after', after); + if (guest && organizationId) query.set('organizationId', organizationId); + const value = await this.orgRequest( + entry, + (guest ? '' : '/' + organizationId + '/channels') + + (query.size ? '?' + query.toString() : ''), + {}, + guest ? '/api/guest-channels' : '/api/organizations', + ); + if ( + !Array.isArray(value.channels) || + value.channels.length > 50 || + (value.nextCursor !== null && + !(guest + ? typeof value.nextCursor === 'string' && /^ci_[a-f0-9]{64}$/.test(value.nextCursor) + : validChannelId(value.nextCursor))) + ) + return fail('INVALID_RESPONSE'); + return { + channels: value.channels.map((raw) => { + const channel = parseChannel(raw); + if ( + guest && + (!/^org_[A-Za-z0-9]+$/.test(channel.organizationId ?? '') || + record(raw).access !== 'guest' || + (organizationId && channel.organizationId !== organizationId)) + ) + return fail('INVALID_RESPONSE'); + return channel; + }), + nextCursor: value.nextCursor as string | null, + }; + } readMessages = ( id: string, organizationId: string, diff --git a/apps/mobile/src/cloud/conversations.ts b/apps/mobile/src/cloud/conversations.ts new file mode 100644 index 0000000000..be750500d1 --- /dev/null +++ b/apps/mobile/src/cloud/conversations.ts @@ -0,0 +1,156 @@ +import { canRetainCloudCache, cloudError, type CloudClient, type CloudRow } from './client'; +import { cloudViewScope } from './workspace-directory'; +import type { CloudChannel } from './channel-contract'; +import { ClientError } from './instance-contract'; + +export type CloudConversation = { + key: string; + scope: string; + instanceId: string; + organizationId: string; + organizationName: string; + guest: boolean; + channel: CloudChannel; +}; +type Port = Pick; +type Snapshot = { busy: boolean; rows: CloudConversation[]; errors: string[] }; + +export function createCloudConversations(port: Port) { + let sources: readonly CloudRow[] = []; + let signature = ''; + let epoch = 0; + let state: Snapshot = { busy: false, rows: [], errors: [] }; + let runningEpoch = 0; + let pending: Promise | undefined; + const listeners = new Set<() => void>(); + const publish = (next: Snapshot) => { + state = next; + for (const listener of listeners) listener(); + }; + const validScopes = () => + new Set(sources.filter((r) => r.hasSession && r.sessionUserId).map(cloudViewScope)); + async function collect(source: CloudRow, version: number) { + const scope = cloudViewScope(source); + const result = new Map(); + const add = ( + channel: CloudChannel, + organizationId: string, + organizationName: string, + guest: boolean, + ) => { + const key = JSON.stringify(['cloud', scope, organizationId, channel.channelId]); + if (!result.has(key)) + result.set(key, { + key, + scope, + instanceId: source.config.instanceId, + organizationId, + organizationName, + guest, + channel, + }); + }; + const pages = async ( + load: (after?: string) => Promise<{ values: T[]; nextCursor: string | null }>, + accept: (values: T[]) => Promise, + ) => { + const seen = new Set(); + let after: string | undefined; + do { + if (version !== epoch) return; + const page = await load(after); + if (version !== epoch) return; + await accept(page.values); + if (!page.nextCursor) return; + if (seen.has(page.nextCursor)) throw new ClientError('INVALID_RESPONSE'); + seen.add(page.nextCursor); + after = page.nextCursor; + } while (version === epoch); + }; + await pages( + async (after) => { + const p = await port.listOrganizations(source.config.instanceId, after); + return { values: p.organizations, nextCursor: p.nextCursor }; + }, + async (organizations) => { + for (const org of organizations) { + await pages( + async (after) => { + const p = await port.listConversationChannels( + source.config.instanceId, + org.organizationId, + after, + ); + return { values: p.channels, nextCursor: p.nextCursor }; + }, + async (channels) => { + for (const c of channels) add(c, org.organizationId, org.name, false); + }, + ); + } + }, + ); + await pages( + async (after) => { + const p = await port.listConversationChannels(source.config.instanceId, '', after, true); + return { values: p.channels, nextCursor: p.nextCursor }; + }, + async (channels) => { + for (const c of channels) { + if (!c.organizationId || !c.organizationName) throw new ClientError('INVALID_RESPONSE'); + add(c, c.organizationId, c.organizationName, true); + } + }, + ); + return [...result.values()]; + } + return { + getSnapshot: () => state, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + enroll(rows: readonly CloudRow[]) { + const next = JSON.stringify(rows.map(cloudViewScope)); + sources = rows; + if (next === signature) return; + signature = next; + epoch++; + const scopes = validScopes(); + publish({ busy: false, errors: [], rows: state.rows.filter((r) => scopes.has(r.scope)) }); + }, + refresh(): Promise { + if (pending) return runningEpoch === epoch ? pending : pending.then(() => this.refresh()); + runningEpoch = epoch; + const version = epoch; + const captured = sources.filter((r) => r.hasSession && r.sessionUserId); + publish({ ...state, busy: true, errors: [] }); + pending = (async () => { + for (const source of captured) { + if (version !== epoch) return; + const scope = cloudViewScope(source); + try { + const rows = await collect(source, version); + if (version !== epoch || !rows) return; + publish({ ...state, rows: [...state.rows.filter((r) => r.scope !== scope), ...rows] }); + } catch (error) { + if (version !== epoch) return; + const code = cloudError(error); + const keep = canRetainCloudCache(error); + publish({ + ...state, + rows: keep ? state.rows : state.rows.filter((r) => r.scope !== scope), + errors: [...state.errors, code], + }); + } + } + })().finally(() => { + pending = undefined; + if (version === epoch) publish({ ...state, busy: false }); + }); + return pending; + }, + }; +} diff --git a/apps/mobile/src/cloud/native-conversations.ts b/apps/mobile/src/cloud/native-conversations.ts new file mode 100644 index 0000000000..572c381b52 --- /dev/null +++ b/apps/mobile/src/cloud/native-conversations.ts @@ -0,0 +1,31 @@ +import { sourceDrafts } from '../host/native-source-drafts'; +import { cloudViewScope } from './workspace-directory'; +import { useSyncExternalStore } from 'react'; +import { cloudClient, useCloudSnapshot } from './native'; +import { createCloudConversations } from './conversations'; +export const cloudConversations = createCloudConversations(cloudClient); +cloudClient.subscribe(() => { + const snapshot = cloudClient.getSnapshot(); + cloudConversations.enroll(snapshot.rows); + if (snapshot.ready) + void sourceDrafts.ready.then(() => { + const scopes = new Set( + cloudClient + .getSnapshot() + .rows.filter((r) => r.hasSession) + .map(cloudViewScope), + ); + sourceDrafts.removeWhere((key) => { + try { + const parts = JSON.parse(key); + return parts[0] === 'cloud' && !scopes.has(parts[1]); + } catch { + return false; + } + }); + }); +}); +export function useCloudConversations() { + useCloudSnapshot(); + return useSyncExternalStore(cloudConversations.subscribe, cloudConversations.getSnapshot); +} diff --git a/apps/mobile/src/cloud/native-message-cache.ts b/apps/mobile/src/cloud/native-message-cache.ts new file mode 100644 index 0000000000..9f290af69e --- /dev/null +++ b/apps/mobile/src/cloud/native-message-cache.ts @@ -0,0 +1,56 @@ +import * as Crypto from 'expo-crypto'; +import { Directory, File, Paths } from 'expo-file-system'; +import { + parseChannel, + parseMessage, + type CloudChannel, + type CloudMessage, +} from './channel-contract'; +const root = () => new Directory(Paths.cache, 'deepseekbot-cloud-messages'); +const file = async (key: string) => + new File( + root(), + (await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, key)) + '.json', + ); +export async function readCloudMessages(key: string) { + try { + const f = await file(key); + if (!f.exists) return undefined; + const raw = JSON.parse(await f.text()); + if ( + !Array.isArray(raw.messages) || + raw.messages.length > 500 || + typeof raw.hasMore !== 'boolean' + ) + return undefined; + return { + channel: parseChannel(raw.channel), + messages: raw.messages.map(parseMessage), + hasMore: raw.hasMore, + }; + } catch { + return undefined; + } +} +export async function cacheCloudMessages( + key: string, + value: { channel: CloudChannel; messages: CloudMessage[]; hasMore: boolean }, +) { + try { + root().create({ intermediates: true, idempotent: true }); + (await file(key)).writeSync( + JSON.stringify({ + ...value, + messages: value.messages.slice(0, 500), + hasMore: value.hasMore || value.messages.length > 500, + }), + ); + } catch {} +} + +export async function removeCloudMessages(key: string) { + try { + const f = await file(key); + if (f.exists) f.delete(); + } catch {} +} diff --git a/apps/mobile/src/host/conversation-filter.ts b/apps/mobile/src/host/conversation-filter.ts new file mode 100644 index 0000000000..9cf6e0231d --- /dev/null +++ b/apps/mobile/src/host/conversation-filter.ts @@ -0,0 +1,38 @@ +export function createConversationFilter(storage: { + read(): Promise; + write(value: string): void; +}) { + let value = 'all'; + let touched = false; + const listeners = new Set<() => void>(); + const publish = () => { + for (const listener of listeners) listener(); + }; + const ready = storage + .read() + .then((stored) => { + if (!touched && typeof stored === 'string' && stored.length < 4096) { + value = stored; + publish(); + } + }) + .catch(() => undefined); + return { + ready, + getSnapshot: () => value, + set(next: string) { + touched = true; + value = next; + try { + storage.write(value); + } catch {} + publish(); + }, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/apps/mobile/src/host/host-session.ts b/apps/mobile/src/host/host-session.ts index a8a2dfb971..29f4763737 100644 --- a/apps/mobile/src/host/host-session.ts +++ b/apps/mobile/src/host/host-session.ts @@ -1,3 +1,4 @@ +import { sourceDrafts } from './native-source-drafts'; import { useCallback, useMemo, useSyncExternalStore } from 'react'; import { useFocusEffect } from 'expo-router'; import { AppState, Platform } from 'react-native'; @@ -26,6 +27,7 @@ export interface HostSessionState { inbox: Inbox | undefined; } +const clientHosts = new WeakMap(); const listeners = new Set<() => void>(); let state: HostSessionState = { saved: undefined, hosts: [], client: undefined, inbox: undefined }; let stored: SavedHosts = { hosts: [] }; @@ -78,6 +80,7 @@ function adopt(next: SavedHosts, trigger: ConnectionTrigger = 'open'): void { carrier: nativeCarrier(), telemetry: createConnectionTelemetry(mobileTelemetry, trigger), }); + if (client && saved) clientHosts.set(client, saved.hostId); const inbox = client === undefined ? undefined : createInbox(client); offInbox = saved === null || inbox === undefined @@ -139,6 +142,15 @@ export const hostSession = { if (host === null || host === undefined) return; const current = host.hostId === state.saved?.hostId; void pushSession.forget(host); + await sourceDrafts.ready; + sourceDrafts.removeWhere((key) => { + try { + const parts = JSON.parse(key); + return parts[0] === 'computer' && parts[1] === host.hostId; + } catch { + return false; + } + }); await commit(withoutHost(stored, host.hostId)); if (!current) return; try { @@ -193,6 +205,7 @@ function conversationFor( host: client, ...options, cache: nativeConversationCache, + cacheKey: JSON.stringify(['computer', clientHosts.get(client), key]), telemetry: { send: () => mobileTelemetry.send(options.botSlug === undefined ? 'group' : 'dm') }, }); let watchers = 0; diff --git a/apps/mobile/src/host/native-conversation-filter.ts b/apps/mobile/src/host/native-conversation-filter.ts new file mode 100644 index 0000000000..3576e6f4f2 --- /dev/null +++ b/apps/mobile/src/host/native-conversation-filter.ts @@ -0,0 +1,19 @@ +import { File, Paths } from 'expo-file-system'; +import { useSyncExternalStore } from 'react'; +import { createConversationFilter } from './conversation-filter'; +const file = () => new File(Paths.document, 'deepseekbot-conversation-filter.json'); +export const conversationFilter = createConversationFilter({ + async read() { + const stored = file(); + return stored.exists ? JSON.parse(await stored.text()) : undefined; + }, + write(value) { + file().writeSync(JSON.stringify(value)); + }, +}); +export function useConversationFilter() { + return [ + useSyncExternalStore(conversationFilter.subscribe, conversationFilter.getSnapshot), + conversationFilter.set, + ] as const; +} diff --git a/apps/mobile/src/host/native-source-drafts.ts b/apps/mobile/src/host/native-source-drafts.ts new file mode 100644 index 0000000000..0647f3d04c --- /dev/null +++ b/apps/mobile/src/host/native-source-drafts.ts @@ -0,0 +1,23 @@ +import { File, Paths } from 'expo-file-system'; +import { useCallback, useSyncExternalStore } from 'react'; +import { createSourceDrafts } from './source-drafts'; +const file = () => new File(Paths.document, 'deepseekbot-unsent-text.json'); +export const sourceDrafts = createSourceDrafts({ + async read() { + const f = file(); + return f.exists ? JSON.parse(await f.text()) : {}; + }, + write(value) { + try { + file().writeSync(JSON.stringify(value)); + } catch {} + }, +}); +export function useSourceDraft(key: string) { + const value = useSyncExternalStore( + sourceDrafts.subscribe, + useCallback(() => sourceDrafts.get(key), [key]), + ); + const setValue = useCallback((next: string) => sourceDrafts.set(key, next), [key]); + return [value, setValue] as const; +} diff --git a/apps/mobile/src/host/source-drafts.ts b/apps/mobile/src/host/source-drafts.ts new file mode 100644 index 0000000000..935bcae716 --- /dev/null +++ b/apps/mobile/src/host/source-drafts.ts @@ -0,0 +1,57 @@ +export interface DraftStorage { + read(): Promise; + write(value: Record): void; +} +export function createSourceDrafts(storage: DraftStorage) { + let values: Record = {}; + const changed = new Set(); + const listeners = new Set<() => void>(); + const publish = () => { + for (const listener of listeners) listener(); + }; + const ready = storage + .read() + .then((raw) => { + if (raw && typeof raw === 'object' && !Array.isArray(raw)) + for (const [key, value] of Object.entries(raw)) { + if (!changed.has(key) && typeof value === 'string' && value.length <= 100000) + values[key] = value; + } + storage.write(values); + publish(); + }) + .catch(() => undefined); + return { + ready, + get: (key: string) => values[key] ?? '', + set(key: string, value: string) { + changed.add(key); + values = { ...values, [key]: value }; + storage.write(values); + publish(); + }, + removeWhere(match: (key: string) => boolean) { + let removed = false; + for (const key of Object.keys(values)) + if (match(key)) { + changed.add(key); + delete values[key]; + removed = true; + } + if (removed) { + storage.write(values); + publish(); + } + }, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} +export const hostDraftKey = (hostId: string, kind: string, id: string) => + JSON.stringify(['computer', hostId, kind, id]); +export const cloudDraftKey = (scope: string, organizationId: string, channelId: string) => + JSON.stringify(['cloud', scope, organizationId, channelId]); diff --git a/apps/mobile/src/ui/chat/channel-title.tsx b/apps/mobile/src/ui/chat/channel-title.tsx index 51cde61358..4231694c66 100644 --- a/apps/mobile/src/ui/chat/channel-title.tsx +++ b/apps/mobile/src/ui/chat/channel-title.tsx @@ -1,3 +1,4 @@ +import { SourceName } from './source-name'; import { Pressable } from 'react-native'; import type { RosterChannel } from '../../host/client'; @@ -43,9 +44,7 @@ export function ChannelTitle({ })} > - - {name} - + {members === undefined ? null : ( {members} diff --git a/apps/mobile/src/ui/chat/chat-title.tsx b/apps/mobile/src/ui/chat/chat-title.tsx index cbdcfee7a8..925bd20f82 100644 --- a/apps/mobile/src/ui/chat/chat-title.tsx +++ b/apps/mobile/src/ui/chat/chat-title.tsx @@ -1,10 +1,10 @@ +import { SourceName } from './source-name'; import { Pressable, View } from 'react-native'; import type { RosterBot } from '../../host/client'; import { BotAvatar } from '../bot-avatar'; import { frosted, useDesignTheme } from '../design/theme'; import { edge, radius, space } from '../design/tokens'; -import { Text } from '../primitives/text'; import { t } from '../strings'; import { botDotTone } from './bot-tone'; @@ -45,9 +45,7 @@ export function ChatTitle({ })} > {bot === undefined ? null : } - - {name} - + {bot === undefined ? null : ( } + disabled={disabled} + onPress={onPress} + /> + ); +} diff --git a/apps/mobile/src/ui/chat/conversation-row.tsx b/apps/mobile/src/ui/chat/conversation-row.tsx index a4c7360f08..c1fd55b71b 100644 --- a/apps/mobile/src/ui/chat/conversation-row.tsx +++ b/apps/mobile/src/ui/chat/conversation-row.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react'; +import { SourceName } from './source-name'; import { Pressable, View } from 'react-native'; import { BotAvatar } from '../bot-avatar'; @@ -23,7 +25,6 @@ export function ConversationRow({ now: Date; onPress?: () => void; }) { - const theme = useDesignTheme(); const title = titleOf(row); const busy = row.kind === 'bot' && (row.bot.state === 'thinking' || row.bot.state === 'working'); const preview = @@ -46,10 +47,70 @@ export function ConversationRow({ ] .filter((part) => part !== undefined) .join(', '); + return ( + + ) : ( + + ) + } + /> + ); +} + +export function ConversationGroupAvatar({ avatar }: { avatar?: string | undefined }) { + return ( + + + + ); +} + +export function ConversationRowFrame({ + title, + source, + label, + preview, + time = '', + busy = false, + badge, + avatar, + onPress, + disabled = false, +}: { + title: string; + source: 'computer' | 'cloud'; + label: string; + preview: string; + time?: string; + busy?: boolean; + badge?: ReturnType; + avatar: ReactNode; + onPress?: (() => void) | undefined; + disabled?: boolean; +}) { + const theme = useDesignTheme(); return ( ({ @@ -62,26 +123,7 @@ export function ConversationRow({ backgroundColor: pressed ? theme.layer.canvas.pressed : theme.layer.canvas.fill, })} > - {row.kind === 'bot' ? ( - - ) : ( - - - - )} + {avatar} - - {title} - + {time === '' ? null : ( {time} diff --git a/apps/mobile/src/ui/chat/conversation-screen.tsx b/apps/mobile/src/ui/chat/conversation-screen.tsx index 450393fa30..edce55726c 100644 --- a/apps/mobile/src/ui/chat/conversation-screen.tsx +++ b/apps/mobile/src/ui/chat/conversation-screen.tsx @@ -1,3 +1,5 @@ +import { useSourceDraft } from '../../host/native-source-drafts'; +import { hostDraftKey } from '../../host/source-drafts'; import { Stack, useIsFocused, useRouter } from 'expo-router'; import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, FlatList, Pressable, View, type ViewInstance } from 'react-native'; @@ -93,31 +95,36 @@ export type ChatPlace = | { kind: 'channel'; channelId: string | undefined }; export function ConversationScreen({ + expectedHostId, place, focus, latestWindowOnly = false, }: { place: ChatPlace; + expectedHostId?: string | undefined; focus: string | undefined; latestWindowOnly?: boolean; }) { - const { client } = useHostSession(); + const { client, saved } = useHostSession(); const focused = useIsFocused(); const roster = useHostSnapshot(client, focused).roster; const router = useRouter(); const identities = useMemo( () => ({ bots: roster?.bots ?? [], - openBot: (slug: string) => router.push({ pathname: '/dm/[slug]', params: { slug } }), + openBot: (slug: string) => + router.push({ pathname: '/dm/[slug]', params: { slug, hostId: saved?.hostId } }), }), - [roster?.bots, router], + [roster?.bots, router, saved?.hostId], ); const bot = place.kind === 'dm' ? roster?.bots.find((entry) => entry.slug === place.slug) : undefined; + if (expectedHostId && saved?.hostId !== expectedHostId) return {t.sourceChanged}; return ( ([]); const [caret, setCaret] = useState(0); const [selection, setSelection] = useState<{ start: number; end: number }>(); @@ -406,6 +419,7 @@ function ChatConversation({ }; const send = () => { + if (host.connection.status !== 'connected') return; const { body, tags } = submittedMessage(draft, mentions); if ((body === '' && files.length === 0) || conversation === undefined) return; if (tags.allBotMention?.preview.recipients.length === 0) return; @@ -655,7 +669,7 @@ function ChatConversation({ ? t.messageBot(`#${channel.name}`) : t.messagePlaceholder } - disabled={conversation === undefined} + disabled={conversation === undefined || host.connection.status !== 'connected'} files={files} canAttach={files.length < MAX_PICKED} dictation={dictation.state.status} diff --git a/apps/mobile/src/ui/chat/conversations.ts b/apps/mobile/src/ui/chat/conversations.ts index 9f8387aa82..09c7d67ff6 100644 --- a/apps/mobile/src/ui/chat/conversations.ts +++ b/apps/mobile/src/ui/chat/conversations.ts @@ -213,7 +213,7 @@ export function titleOf(row: Conversation): string { return row.kind === 'bot' ? row.bot.displayName : row.channel.name; } -function searchKey(value: string): string { +export function searchKey(value: string): string { return value.normalize('NFKC').toLocaleLowerCase().replace(/\s+/gu, ' ').trim(); } diff --git a/apps/mobile/src/ui/chat/source-name.tsx b/apps/mobile/src/ui/chat/source-name.tsx new file mode 100644 index 0000000000..76739ab7e3 --- /dev/null +++ b/apps/mobile/src/ui/chat/source-name.tsx @@ -0,0 +1,24 @@ +import { View } from 'react-native'; +import { useDesignTheme } from '../design/theme'; +import { space } from '../design/tokens'; +import { Text } from '../primitives/text'; +import { CloudIcon, ComputerIcon } from '../shell/icons'; +import { t } from '../strings'; +export function SourceName({ name, source }: { name: string; source: 'computer' | 'cloud' }) { + const theme = useDesignTheme(); + const Icon = source === 'cloud' ? CloudIcon : ComputerIcon; + return ( + + + {name} + + + + + + ); +} diff --git a/apps/mobile/src/ui/cloud/channels-screen.tsx b/apps/mobile/src/ui/cloud/channels-screen.tsx index 41e1a3a4b3..3b219ca239 100644 --- a/apps/mobile/src/ui/cloud/channels-screen.tsx +++ b/apps/mobile/src/ui/cloud/channels-screen.tsx @@ -1,8 +1,15 @@ +import { useSourceDraft } from '../../host/native-source-drafts'; +import { cloudDraftKey } from '../../host/source-drafts'; import { router, useFocusEffect, useLocalSearchParams } from 'expo-router'; import { useCallback, useRef, useState } from 'react'; import { FlatList, View, type ScrollViewProps } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { cloudError } from '../../cloud/client'; +import { + readCloudMessages, + cacheCloudMessages, + removeCloudMessages, +} from '../../cloud/native-message-cache'; +import { canRetainCloudCache, cloudError } from '../../cloud/client'; import type { CloudChannel, CloudMessage } from '../../cloud/channel-contract'; import { cloudClient, useCloudSnapshot } from '../../cloud/native'; import { cloudViewScope } from '../../cloud/workspace-directory'; @@ -39,12 +46,18 @@ function renderGuestChannelFormScroll(props: ScrollViewProps) { export function CloudChannelsScreen() { const snapshot = useCloudSnapshot(); - const { instanceId, organizationId, guest, channelId } = useLocalSearchParams<{ + const { instanceId, organizationId, guest, channelId, accountScope } = useLocalSearchParams<{ + accountScope?: string; instanceId: string; organizationId?: string; guest?: string; channelId?: string; }>(); + if ( + accountScope && + accountScope !== cloudViewScope(snapshot.rows.find((r) => r.config.instanceId === instanceId)) + ) + return {t.cloudSourceChanged}; return ( r.config.instanceId === instanceId); - const allowed = !!row?.identity && (guest || row.organization?.organizationId === organizationId); + const allowed = !!row?.hasSession && !!row.sessionUserId; const [channels, setChannels] = useState(); const [channelCursor, setChannelCursor] = useState(null); const [selected, setSelected] = useState(); const [messages, setMessages] = useState(); const [cursor, setCursor] = useState(0), [more, setMore] = useState(false); - const [name, setName] = useState(''), - [draft, setDraft] = useState(''); + const [name, setName] = useState(''); + const [draft, setDraft] = useSourceDraft( + cloudDraftKey( + cloudViewScope(row), + organizationId, + selected?.channelId ?? initialChannelId ?? '', + ), + ); const [working, setWorking] = useState(false), [error, setError] = useState(), [notice, setNotice] = useState(); const busy = working || snapshot.busy; + const cacheScope = cloudDraftKey(cloudViewScope(row), organizationId, initialChannelId ?? ''); + const loadedHistory = useRef(false); + const attemptedChannelId = useRef(initialChannelId); + const messageWindow = useRef([]); + useFocusEffect( + useCallback(() => { + let active = true; + if (initialChannelId && allowed) + void readCloudMessages(cacheScope).then((cached) => { + if ( + !active || + loadedHistory.current || + !cached || + cached.channel.channelId !== initialChannelId + ) + return; + messageWindow.current = cached.messages; + setSelected(cached.channel); + setMessages(cached.messages); + setCursor(cached.messages.at(-1)?.sequence ?? 0); + setMore(cached.hasMore); + }); + return () => { + active = false; + }; + }, [cacheScope, initialChannelId, allowed]), + ); const opened = useRef(false); useFocusEffect( useCallback(() => { @@ -139,6 +185,16 @@ function ChannelWorkspace({ try { await action(); } catch (e) { + if (!canRetainCloudCache(e)) { + loadedHistory.current = true; + messageWindow.current = []; + setSelected(undefined); + setMessages(undefined); + setMore(false); + void removeCloudMessages( + cloudDraftKey(cloudViewScope(row), organizationId, attemptedChannelId.current ?? ''), + ); + } setError(cloudError(e)); } finally { setWorking(false); @@ -157,6 +213,7 @@ function ChannelWorkspace({ setChannelCursor(page.nextCursor); }; const history = async (channel: CloudChannel, after = 0) => { + attemptedChannelId.current = channel.channelId; const page = await cloudClient.readMessages( instanceId, organizationId, @@ -164,14 +221,22 @@ function ChannelWorkspace({ after, guest, ); - setMessages((old) => - after - ? [ - ...(old ?? []), - ...page.messages.filter((m) => !old?.some((o) => o.sequence === m.sequence)), - ] - : page.messages, - ); + loadedHistory.current = true; + const next = after + ? [ + ...messageWindow.current, + ...page.messages.filter( + (m) => !messageWindow.current.some((old) => old.sequence === m.sequence), + ), + ] + : page.messages; + messageWindow.current = next; + setMessages(next); + void cacheCloudMessages(cloudDraftKey(cloudViewScope(row), organizationId, channel.channelId), { + channel, + messages: next, + hasMore: page.hasMore, + }); setCursor(page.nextCursor); setMore(page.hasMore); }; @@ -265,7 +330,6 @@ function ChannelWorkspace({ onPress={() => { setSelected(undefined); setMessages(undefined); - setDraft(''); setNotice(undefined); }} /> @@ -371,7 +435,6 @@ function ChannelWorkspace({ void run(async () => { setSelected(item); setMessages(undefined); - setDraft(''); await history(item); }) } diff --git a/apps/mobile/src/ui/shell/open-conversation.ts b/apps/mobile/src/ui/shell/open-conversation.ts index c353412961..13362d1c9e 100644 --- a/apps/mobile/src/ui/shell/open-conversation.ts +++ b/apps/mobile/src/ui/shell/open-conversation.ts @@ -5,7 +5,9 @@ import type { Conversation } from '../chat/conversations'; 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 } }); - else router.push({ pathname: '/channel/[id]', params: { id: row.channel.id } }); + 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 } }); } diff --git a/apps/mobile/src/ui/strings.ts b/apps/mobile/src/ui/strings.ts index 6b1dc48918..04d1887961 100644 --- a/apps/mobile/src/ui/strings.ts +++ b/apps/mobile/src/ui/strings.ts @@ -12,6 +12,14 @@ function toolGroup(tool: string): ToolGroup { } const en = { + allConversations: 'All conversations', + computerChannel: 'Computer conversation', + cloudListUnavailable: 'Some cloud conversations could not be refreshed. Pull to retry.', + sourceChanged: 'This conversation belongs to another Computer. Select that Computer to continue.', + cloudSourceChanged: + 'This conversation belongs to a different cloud sign-in. Open it again from your conversation list.', + conversationDetails: 'Conversation details', + recentNotLoaded: 'Open to load messages', dialogFailed: 'That did not go through. Try again.', openMention: (name: string) => `Open chat with ${name}`, connectionName: 'Name on this phone', @@ -965,6 +973,13 @@ const en = { }; const zh: typeof en = { + allConversations: '全部会话', + computerChannel: '电脑会话', + cloudListUnavailable: '部分云会话未能刷新,下拉可重试。', + sourceChanged: '此会话属于另一台电脑,请选择对应电脑后继续。', + cloudSourceChanged: '此会话属于另一个云端登录身份,请从会话列表重新打开。', + conversationDetails: '会话详情', + recentNotLoaded: '打开以加载消息', dialogFailed: '操作没有成功,请重试。', openMention: (name: string) => `打开与${name}的私聊`, connectionName: '在这台手机上的名称', diff --git a/apps/mobile/test/cloud-channel-cache.test.tsx b/apps/mobile/test/cloud-channel-cache.test.tsx new file mode 100644 index 0000000000..0608d2ecd4 --- /dev/null +++ b/apps/mobile/test/cloud-channel-cache.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ClientError } from '../src/cloud/instance-contract'; +const state = vi.hoisted(() => ({ + initialChannelId: 'channel_a' as string | undefined, + messages: vi.fn(), + read: vi.fn(), + remove: vi.fn(), + open: vi.fn(), +})); +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})); +vi.mock('react-native', () => import('./react-native-shim')); +vi.mock('react-native-reanimated', () => import('./reanimated-shim')); +vi.mock('react-native-keyboard-controller', () => import('./keyboard-controller-shim')); +vi.mock('react-native-worklets', () => ({ scheduleOnRN: (fn: () => void) => fn() })); +vi.mock('react-native-svg', () => ({ default: () => null, Path: () => null })); +vi.mock('expo-router', async () => { + const { useEffect } = await import('react'); + return { + router: { push: vi.fn() }, + useFocusEffect: (callback: () => void) => useEffect(callback, [callback]), + useLocalSearchParams: () => ({ + instanceId: 'uat', + organizationId: 'org_a', + channelId: state.initialChannelId, + }), + }; +}); +vi.mock('../src/host/native-source-drafts', async () => { + const { useState } = await import('react'); + return { useSourceDraft: () => useState('') }; +}); +vi.mock('../src/cloud/workspace-directory', () => ({ cloudViewScope: () => 'account_a' })); +vi.mock('../src/cloud/native-message-cache', () => ({ + readCloudMessages: state.read, + removeCloudMessages: state.remove, + cacheCloudMessages: vi.fn(), +})); +vi.mock('../src/cloud/native', () => ({ + cloudClient: { + openChannel: state.open, + whoami: async () => {}, + listChannels: async () => ({ + channels: [{ channelId: 'channel_b', name: 'B' }], + nextCursor: null, + }), + readMessages: state.messages, + }, + useCloudSnapshot: () => ({ + ready: true, + busy: false, + rows: [ + { + config: { instanceId: 'uat' }, + hasSession: true, + sessionUserId: 'user_a', + organization: { role: 'member', name: 'A' }, + }, + ], + }), +})); +import { CloudChannelsScreen } from '../src/ui/cloud/channels-screen'; +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +const cached = { + channel: { channelId: 'channel_a', name: 'Private channel' }, + messages: [{ sequence: 1, text: 'Cached private text', authorId: 'a', authorType: 'human' }], + hasMore: false, +}; +afterEach(() => { + state.initialChannelId = 'channel_a'; +}); +describe('cloud channel cached access', () => { + it.each(['CHANNEL_ACCESS_DENIED', 'NETWORK_UNAVAILABLE'])( + 'handles %s after cache has loaded', + async (code) => { + state.remove.mockReset(); + state.read.mockResolvedValue(cached); + let reject: (error: Error) => void = () => {}; + state.open.mockImplementation( + () => + new Promise((_resolve, fail) => { + reject = fail; + }), + ); + const node = document.createElement('div'); + const root = createRoot(node); + await act(async () => root.render()); + expect(node.textContent).toContain('Cached private text'); + await act(async () => reject(new ClientError(code))); + if (code === 'NETWORK_UNAVAILABLE') { + expect(node.textContent).toContain('Cached private text'); + expect(state.remove).not.toHaveBeenCalled(); + } else { + expect(node.textContent).not.toContain('Cached private text'); + expect(state.remove).toHaveBeenCalled(); + } + act(() => root.unmount()); + }, + ); + it('invalidates the attempted list channel rather than the previous render selection', async () => { + state.initialChannelId = undefined; + state.remove.mockReset(); + state.messages.mockRejectedValue(new ClientError('CHANNEL_ACCESS_DENIED')); + const node = document.createElement('div'); + const root = createRoot(node); + await act(async () => root.render()); + const button = [...node.querySelectorAll('button')].find((entry) => + entry.textContent?.endsWith(': B'), + ); + expect(button).toBeDefined(); + await act(async () => button!.click()); + expect(state.remove).toHaveBeenCalledWith( + JSON.stringify(['cloud', 'account_a', 'org_a', 'channel_b']), + ); + act(() => root.unmount()); + }); + it('does not resurrect a delayed cache after permission denial', async () => { + let release: (value: unknown) => void = () => {}; + state.read.mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + state.open.mockRejectedValue(new ClientError('CHANNEL_ACCESS_DENIED')); + const node = document.createElement('div'); + const root = createRoot(node); + await act(async () => root.render()); + await act(async () => release(cached)); + expect(node.textContent).not.toContain('Cached private text'); + act(() => root.unmount()); + }); +}); diff --git a/apps/mobile/test/cloud-client.test.ts b/apps/mobile/test/cloud-client.test.ts index 312ae3c1e0..c7196bab84 100644 --- a/apps/mobile/test/cloud-client.test.ts +++ b/apps/mobile/test/cloud-client.test.ts @@ -385,6 +385,53 @@ function organizationsFixture() { return { ...f, state, operations }; } describe('cloud organizations', () => { + it.each([true, false])( + 'lists another organization and cold-restores the selected workspace (has selection: %s)', + async (selected) => { + const f = organizationsFixture(); + const original = f.rt.request; + f.rt.request = async (url, init) => { + if (url.endsWith('/org_b/access') && !f.state.denied) + return Response.json({ organizationId: 'org_b', role: 'member' }); + if (url.endsWith('/org_b/channels')) { + expect(f.state.selected).toBe('org_b'); + return Response.json({ + channels: [ + { + channelId: 'ch_' + 'b'.repeat(64), + name: 'Elsewhere', + creatorId: 'user_test', + creatorType: 'human', + createdAt: '2026-10-18T00:00:00Z', + }, + ], + nextCursor: null, + }); + } + return original(url, init); + }; + const client = new CloudClient(f.rt); + await client.importConfig(JSON.stringify(config)); + await client.login(config.instanceId); + if (selected) await client.useOrganization(config.instanceId, 'org_a'); + const page = await client.listConversationChannels(config.instanceId, 'org_b'); + expect(page.channels[0]?.name).toBe('Elsewhere'); + expect(client.getSnapshot().rows[0]?.organization?.organizationId).toBe( + selected ? 'org_a' : undefined, + ); + if (selected) expect(f.state.selected).toBe('org_a'); + const restored = new CloudClient(f.rt); + await restored.load(); + expect(restored.getSnapshot().ready).toBe(true); + await restored.whoami(config.instanceId); + expect(JSON.stringify(page)).not.toContain('secret-refresh'); + f.state.denied = true; + await expect( + client.listConversationChannels(config.instanceId, 'org_b'), + ).rejects.toMatchObject({ code: 'ORGANIZATION_ACCESS_DENIED' }); + }, + ); + it('creates, selects through WorkOS refresh, updates, and revalidates the same organization after restart', async () => { const f = organizationsFixture(); const client = new CloudClient(f.rt); diff --git a/apps/mobile/test/cloud-conversations.test.ts b/apps/mobile/test/cloud-conversations.test.ts new file mode 100644 index 0000000000..3befd5082b --- /dev/null +++ b/apps/mobile/test/cloud-conversations.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { createCloudConversations } from '../src/cloud/conversations'; +import { publicInstance, MOBILE_REDIRECT_URI } from '../src/cloud/instance-contract'; +import type { CloudRow } from '../src/cloud/client'; +const row: CloudRow = { + config: publicInstance({ + instanceId: 'uat-one', + environment: 'uat', + server: 'https://uat.example.test', + clientId: 'client_test', + issuer: 'https://api.workos.com/user_management/client_test', + authkitDomain: 'test-staging.authkit.app', + mobileRedirectUri: MOBILE_REDIRECT_URI, + }), + hasSession: true, + sessionUserId: 'user_a', +}; +const channel = { + channelId: 'ch_' + 'a'.repeat(64), + name: 'General', + creatorId: 'user_a', + creatorType: 'human' as const, + createdAt: '2026-10-18T00:00:00Z', +}; +const organization = { + organizationId: 'org_a', + name: 'A', + description: '', + version: 1, + role: 'member' as const, +}; +describe('cloud conversation directory', () => { + it('retains rows on network failure, removes them on logout, and rejects late reads from the previous account', async () => { + let offline = false; + let release: (() => void) | undefined; + let gate: Promise | undefined; + const directory = createCloudConversations({ + listOrganizations: async () => { + if (offline) throw Error('offline'); + await gate; + return { organizations: [organization], nextCursor: null }; + }, + listConversationChannels: async (_id, _org, _after, guest) => ({ + channels: guest ? [] : [channel], + nextCursor: null, + }), + }); + directory.enroll([row]); + await directory.refresh(); + expect(directory.getSnapshot().rows.map((r) => r.channel.name)).toEqual(['General']); + offline = true; + await directory.refresh(); + expect(directory.getSnapshot().rows).toHaveLength(1); + expect(directory.getSnapshot().errors).toHaveLength(1); + offline = false; + gate = new Promise((resolve) => { + release = resolve; + }); + const pending = directory.refresh(); + directory.enroll([{ ...row, sessionUserId: 'user_b' }]); + expect(directory.getSnapshot().rows).toHaveLength(0); + release?.(); + await pending; + expect(directory.getSnapshot().rows).toHaveLength(0); + directory.enroll([{ ...row, hasSession: false }]); + await directory.refresh(); + expect(directory.getSnapshot().rows).toHaveLength(0); + }); + it('keeps guest grants scoped and deduplicates the same channel when also visible through membership', async () => { + const directory = createCloudConversations({ + listOrganizations: async () => ({ organizations: [organization], nextCursor: null }), + listConversationChannels: async (_id, _org, _after, guest) => ({ + channels: guest + ? [ + { ...channel, organizationId: 'org_a', organizationName: 'A' }, + { + ...channel, + channelId: 'ch_' + 'b'.repeat(64), + organizationId: 'org_guest', + organizationName: 'Guest', + }, + ] + : [channel], + nextCursor: null, + }), + }); + directory.enroll([row]); + await directory.refresh(); + expect(directory.getSnapshot().rows.map((r) => [r.organizationId, r.guest])).toEqual([ + ['org_a', false], + ['org_guest', true], + ]); + }); +}); diff --git a/apps/mobile/test/conversation-filter.test.ts b/apps/mobile/test/conversation-filter.test.ts new file mode 100644 index 0000000000..c6ed077671 --- /dev/null +++ b/apps/mobile/test/conversation-filter.test.ts @@ -0,0 +1,19 @@ +import { expect, it } from 'vitest'; +import { createConversationFilter } from '../src/host/conversation-filter'; +it('restores a source-scoped choice and does not overwrite a newer choice during hydration', async () => { + let value: unknown = JSON.stringify(['computer', 'host_a']); + const storage = { + read: async () => value, + write: (next: string) => { + value = next; + }, + }; + const restored = createConversationFilter(storage); + await restored.ready; + expect(restored.getSnapshot()).toBe(value); + const next = createConversationFilter(storage); + next.set(JSON.stringify(['cloud_account_b', 'org_b'])); + await next.ready; + expect(next.getSnapshot()).toBe(value); + expect(next.getSnapshot()).toContain('org_b'); +}); diff --git a/apps/mobile/test/conversation-navigation.test.tsx b/apps/mobile/test/conversation-navigation.test.tsx index e564d534ce..0f432413e9 100644 --- a/apps/mobile/test/conversation-navigation.test.tsx +++ b/apps/mobile/test/conversation-navigation.test.tsx @@ -7,6 +7,7 @@ import type { ConversationSnapshot } from '../src/host/conversation'; const state = vi.hoisted(() => ({ history: [] as string[], + hostId: 'computer_a', host: { connection: { status: 'connected' }, roster: { bots: [] as RosterBot[], channels: [] } }, conversation: { status: 'live', @@ -20,6 +21,7 @@ const state = vi.hoisted(() => ({ display: { mode: 'streaming', loaded: true }, })); +vi.mock('../src/host/native-source-drafts', () => ({ useSourceDraft: () => ['', () => {}] })); vi.mock('expo-router', () => ({ useIsFocused: () => true, Stack: { Screen: () => null }, @@ -41,7 +43,7 @@ vi.mock('react-native', async () => { vi.mock('react-native-reanimated', () => import('./reanimated-shim')); vi.mock('react-native-worklets', () => ({ scheduleOnRN: (callback: () => void) => callback() })); vi.mock('../src/host/host-session', () => ({ - useHostSession: () => ({ client: undefined, inbox: undefined }), + useHostSession: () => ({ client: undefined, inbox: undefined, saved: { hostId: state.hostId } }), useHostSnapshot: () => state.host, useConversation: () => (state.active ? {} : undefined), useChannelConversation: () => undefined, @@ -126,6 +128,7 @@ let root: Root | undefined; let container: HTMLElement | undefined; beforeEach(() => { + state.hostId = 'computer_a'; state.host.connection.status = 'connected'; state.conversation.status = 'live'; state.conversation.messages = []; @@ -135,6 +138,19 @@ beforeEach(() => { state.display = { mode: 'streaming', loaded: true }; }); +it('refuses a retained route after switching Computers even when its Bot slug collides', async () => { + const place: ChatPlace = { kind: 'dm', slug: 'ada' }; + const view = await render(place); + state.hostId = 'computer_b'; + await act(async () => + root!.render( + , + ), + ); + expect(view.textContent).toContain(t.sourceChanged); + expect(view.querySelector('[data-testid="chat-controls"]')).toBeNull(); +}); + it('keeps the bottom fade at the message viewport edge beneath the composer controls', async () => { const view = await render({ kind: 'dm', slug: 'ada' }); const content = view.querySelector('[data-testid="chat-content"]')!; diff --git a/apps/mobile/test/create-button.test.tsx b/apps/mobile/test/create-button.test.tsx index 7d66ad3c0e..f737ca7394 100644 --- a/apps/mobile/test/create-button.test.tsx +++ b/apps/mobile/test/create-button.test.tsx @@ -10,6 +10,18 @@ const { push, platform, showActionSheetWithOptions, alert } = vi.hoisted(() => ( alert: vi.fn(), })); +vi.mock('../src/host/native-conversation-filter', async () => { + const { useState } = await import('react'); + return { useConversationFilter: () => useState('all') }; +}); +vi.mock('../src/cloud/native', () => ({ + useCloudSnapshot: () => ({ ready: true, busy: false, rows: [] }), + cloudClient: {}, +})); +vi.mock('../src/cloud/native-conversations', () => ({ + useCloudConversations: () => ({ busy: false, rows: [], errors: [] }), + cloudConversations: { refresh: async () => {} }, +})); vi.mock('react-native-keyboard-controller', async () => import('./keyboard-controller-shim')); vi.mock('react-native', async () => ({ ...(await import('./react-native-shim')), @@ -29,6 +41,7 @@ vi.mock('expo-router', () => { Stack, router: { push }, useIsFocused: () => true, + useFocusEffect: () => {}, useRouter: () => ({ push }), }; }); diff --git a/apps/mobile/test/home-search.test.tsx b/apps/mobile/test/home-search.test.tsx index d956bd36ff..cd39d59396 100644 --- a/apps/mobile/test/home-search.test.tsx +++ b/apps/mobile/test/home-search.test.tsx @@ -3,9 +3,22 @@ import { act, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CloudRow } from '../src/cloud/client'; +import type { CloudConversation } from '../src/cloud/conversations'; +import { publicInstance, MOBILE_REDIRECT_URI } from '../src/cloud/instance-contract'; +import { cloudViewScope } from '../src/cloud/workspace-directory'; import type { RosterView } from '../src/host/client'; -const { push } = vi.hoisted(() => ({ push: vi.fn() })); +const { push, cloudState, directoryState } = vi.hoisted(() => ({ + push: vi.fn(), + cloudState: { + error: undefined as string | undefined, + ready: true, + busy: false, + rows: [] as CloudRow[], + }, + directoryState: { busy: false, rows: [] as CloudConversation[], errors: [] as string[] }, +})); const roster: RosterView = { bots: [ { @@ -32,6 +45,18 @@ const roster: RosterView = { channels: [{ id: 'launch-team', name: 'Launch team', memberCount: 3, members: [] }], }; +vi.mock('../src/host/native-conversation-filter', async () => { + const { useState } = await import('react'); + return { useConversationFilter: () => useState('all') }; +}); +vi.mock('../src/cloud/native', () => ({ + useCloudSnapshot: () => cloudState, + cloudClient: { getSnapshot: () => cloudState }, +})); +vi.mock('../src/cloud/native-conversations', () => ({ + useCloudConversations: () => directoryState, + cloudConversations: { refresh: async () => {} }, +})); vi.mock('react-native-keyboard-controller', async () => import('./keyboard-controller-shim')); vi.mock('react-native', () => import('./react-native-shim')); vi.mock('react-native-reanimated', () => import('./reanimated-shim')); @@ -45,6 +70,7 @@ vi.mock('expo-router', () => { Stack, router: { push }, useIsFocused: () => true, + useFocusEffect: () => {}, useRouter: () => ({ push }), }; }); @@ -87,6 +113,10 @@ afterEach(() => { host?.remove(); root = undefined; push.mockReset(); + cloudState.rows = []; + cloudState.ready = true; + cloudState.error = undefined; + directoryState.rows = []; }); async function home(): Promise { @@ -110,20 +140,104 @@ async function type(view: HTMLElement, text: string): Promise { const rows = (view: HTMLElement) => [...view.querySelectorAll('button')] - .filter((node) => node.getAttribute('aria-label') !== t.cloudJoin.newOrJoin) + .filter( + (node) => + ![t.cloudJoin.newOrJoin, t.allConversations, 'studio'].includes( + node.getAttribute('aria-label') ?? '', + ), + ) .map((node) => node.getAttribute('aria-label')); describe('Search at the top of Chats', () => { + it('keeps the Computer usable when Cloud local state cannot load', async () => { + cloudState.ready = false; + cloudState.error = 'STORAGE_UNAVAILABLE'; + const view = await home(); + expect(view.textContent).toContain('Ada'); + expect(view.textContent).toContain(t.cloudListUnavailable); + }); + it('opens a cloud row with its account and organization while leaving the paired Computer selected', async () => { + const config = publicInstance({ + instanceId: 'uat', + environment: 'uat', + server: 'https://uat.example.test', + clientId: 'client_test', + issuer: 'https://api.workos.com/user_management/client_test', + authkitDomain: 'test-staging.authkit.app', + mobileRedirectUri: MOBILE_REDIRECT_URI, + }); + const cloudRow: CloudRow = { + config, + hasSession: true, + sessionUserId: 'user_a', + identity: { instanceId: 'uat', userId: 'user_a', instanceRole: 'user' }, + organization: { + organizationId: 'org_a', + name: 'Cloud team', + description: '', + version: 1, + role: 'member', + }, + }; + const scope = cloudViewScope(cloudRow); + cloudState.rows = [cloudRow]; + directoryState.rows = [ + { + key: 'cloud-one', + scope, + instanceId: 'uat', + organizationId: 'org_a', + organizationName: 'Cloud team', + guest: false, + channel: { + channelId: 'ch_' + 'a'.repeat(64), + name: 'Cloud general', + creatorId: 'user_a', + creatorType: 'human', + createdAt: '2026-10-18T00:00:00Z', + }, + }, + ]; + const view = await home(); + expect(view.textContent).toContain('Cloud general'); + expect(view.textContent).toContain('Ada'); + await act(async () => + view + .querySelector( + `button[aria-label="Cloud general, ${t.cloudChannels.cloudChannel}"]`, + )! + .click(), + ); + expect(push).toHaveBeenLastCalledWith({ + pathname: '/cloud-channels', + params: { + instanceId: 'uat', + organizationId: 'org_a', + channelId: 'ch_' + 'a'.repeat(64), + guest: 'false', + accountScope: scope, + }, + }); + await act(async () => + view.querySelector('button[aria-label="studio"]')!.click(), + ); + expect(view.textContent).not.toContain('Cloud general'); + expect(view.textContent).toContain('Ada'); + }); + it('filters the list in place and opens the chat the Human taps', async () => { const view = await home(); expect(rows(view)).toHaveLength(3); - await type(view, 'AD'); + await type(view, 'AD'); expect(rows(view)).toEqual(['Ada, Done']); await act(async () => view.querySelector('button[aria-label="Ada, Done"]')!.click(), ); - expect(push).toHaveBeenLastCalledWith({ pathname: '/dm/[slug]', params: { slug: 'ada' } }); + expect(push).toHaveBeenLastCalledWith({ + pathname: '/dm/[slug]', + params: { slug: 'ada', hostId: 'h1' }, + }); await type(view, '小明'); expect(rows(view)).toEqual(['小明']); @@ -131,12 +245,12 @@ describe('Search at the top of Chats', () => { await type(view, 'launch'); await act(async () => [...view.querySelectorAll('button')] - .find((node) => node.getAttribute('aria-label') !== t.cloudJoin.newOrJoin)! + .find((node) => node.getAttribute('aria-label')?.startsWith('Launch team'))! .click(), ); expect(push).toHaveBeenLastCalledWith({ pathname: '/channel/[id]', - params: { id: 'launch-team' }, + params: { id: 'launch-team', hostId: 'h1' }, }); }); diff --git a/apps/mobile/test/source-drafts.test.ts b/apps/mobile/test/source-drafts.test.ts new file mode 100644 index 0000000000..db25d7dcb0 --- /dev/null +++ b/apps/mobile/test/source-drafts.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { createSourceDrafts, hostDraftKey, cloudDraftKey } from '../src/host/source-drafts'; +describe('unsent source drafts', () => { + it('keeps colliding channel IDs and accounts separate across reload without transport', async () => { + let disk: Record = {}; + const storage = { + read: async () => disk, + write: (value: Record) => { + disk = { ...value }; + }, + }; + const drafts = createSourceDrafts(storage); + await drafts.ready; + const a = hostDraftKey('host-a', 'channel', 'same'); + const b = hostDraftKey('host-b', 'channel', 'same'); + const c = cloudDraftKey('account-a', 'org_a', 'same'); + drafts.set(a, 'Unsent A'); + drafts.set(b, 'Unsent B'); + drafts.set(c, 'Cloud'); + const reopened = createSourceDrafts(storage); + await reopened.ready; + expect([reopened.get(a), reopened.get(b), reopened.get(c)]).toEqual([ + 'Unsent A', + 'Unsent B', + 'Cloud', + ]); + expect(reopened.get(cloudDraftKey('account-b', 'org_a', 'same'))).toBe(''); + reopened.set(a, ''); + expect(reopened.get(b)).toBe('Unsent B'); + }); + it('never overwrites input typed while storage is loading', async () => { + let release: ((raw: unknown) => void) | undefined; + const drafts = createSourceDrafts({ + read: () => + new Promise((resolve) => { + release = resolve; + }), + write() {}, + }); + drafts.set('channel', 'New input'); + release?.({ channel: 'Old input' }); + await drafts.ready; + expect(drafts.get('channel')).toBe('New input'); + }); +}); diff --git a/changes/2179-unified-mobile-conversations.md b/changes/2179-unified-mobile-conversations.md new file mode 100644 index 0000000000..fe5ec9d003 --- /dev/null +++ b/changes/2179-unified-mobile-conversations.md @@ -0,0 +1,11 @@ +--- +section: Added +--- + +## en + +- Show the selected Computer's conversations alongside signed-in cloud channels on mobile, with source marks, organization filters and source-bound navigation. Keep unsent text scoped to its Computer or cloud account/channel and disable Computer sends while offline. ([#2179](https://github.com/BotHarness/DeepSeekBot/issues/2179)) + +## zh + +- 手机会话列表同时显示当前电脑会话和已登录云频道,提供来源图标、组织筛选及来源绑定的导航。未发送文字按电脑或云账号/频道保存,电脑离线时不可发送。([#2179](https://github.com/BotHarness/DeepSeekBot/issues/2179)) diff --git a/docs/architecture/botharness-architecture.md b/docs/architecture/botharness-architecture.md index 490f05ea89..48d81b21c7 100644 --- a/docs/architecture/botharness-architecture.md +++ b/docs/architecture/botharness-architecture.md @@ -1097,3 +1097,7 @@ A channel-scoped TOB Agent can be explicitly imported into a Host and bound thro ### Mobile workspace directory (#2161) The root shell renders the existing drawer once across computer and cloud screens. HostSession retains paired-computer authority; CloudClient retains cloud configuration and sessions. An in-memory, paginated directory combines verified organization memberships with organization identity obtained from authorized channel-guest listings. Membership wins per instance/organization. The directory is presentation only: guest selection calls filtered guest routes and cannot acquire organization membership or sibling channels. Opening refreshes current grants; closing invalidates old responses. See the unified-navigation extension in [ADR-0229](../adr/0229-mobile-cloud-sessions-are-independent-of-host-pairing.md). Source tests do not replace native switch/revoke/large-text acceptance. + +### Mobile mixed conversation entry (#2179) + +The application-defined mobile conversation directory combines the selected Host roster and the authenticated cloud account's paginated organization/guest Channel listings. It is a client projection: Computer messages remain Host-owned; cloud messages remain TOB-owned. Cloud directory reads borrow and restore organization token scope under the existing CloudClient lock without changing the selected workspace. Account/config changes discard late directory results. Host routes bind a Host ID and cloud routes bind an account/config scope, refusing an old destination after a source change. Host caches and unsent text are source-namespaced; offline Computer sends are disabled and text drafts have no automatic retry transport. This first slice retains one active Host; parallel Hosts and Web/Desktop follow under #2180. Cloud server read/pin/recent-message summaries follow under #2181, and ordinary cloud wake policy under #2182. Source checks are not native/UAT qualification.