Skip to content
Merged
33 changes: 33 additions & 0 deletions .github/pr-screenshots/2179-unified-conversations/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
272 changes: 195 additions & 77 deletions apps/mobile/app/(tabs)/(home)/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
Expand All @@ -42,109 +41,228 @@ 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<string>();
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<string, string>([['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 (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator />
</View>
);
if (saved === null) return <Redirect href="/cloud-join" />;

if (saved === null && cloud.rows.length === 0) return <Redirect href="/cloud-join" />;
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 (
<>
<Stack.Screen options={{ title: shownHostName(look, saved.hostName), headerShown: false }} />
<Stack.Screen options={{ title: t.allConversations, headerShown: false }} />
<HostDrawerEdge>
<HomeHeader
hostName={shownHostName(look, saved.hostName)}
hostName={t.allConversations}
look={look}
actions={<SettingsButton onPress={() => router.push('/settings')} />}
>
<Animated.FlatList
<Animated.FlatList<Row>
renderScrollComponent={renderKeyboardFormScroll}
onScroll={onScroll}
scrollEventThrottle={1}
contentContainerStyle={{ paddingBottom: bottom + space.x6 }}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
data={rows}
contentInsetAdjustmentBehavior="automatic"
keyExtractor={(row) => row.key}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} />}
keyExtractor={(r) => r.key}
refreshControl={
<RefreshControl
refreshing={refreshing || directory.busy}
onRefresh={() => void refresh()}
/>
}
ListHeaderComponent={
<>
<ConnectionBanner
connection={snapshot.connection}
onRetry={() => client?.retryNow()}
onPairAgain={() => router.push('/pair')}
/>
{saved && (
<ConnectionBanner
connection={snapshot.connection}
onRetry={() => client?.retryNow()}
onPairAgain={() => router.push('/pair')}
/>
)}
<SearchField
inset={space.x5}
value={query}
placeholder={t.searchPlaceholder}
onChangeText={setQuery}
trailing={<CreateButton onPick={create} />}
trailing={saved ? <CreateButton onPick={create} /> : undefined}
/>
<ScrollView
horizontal
contentContainerStyle={{
paddingHorizontal: space.x5,
paddingVertical: space.x3,
gap: space.x2,
}}
showsHorizontalScrollIndicator={false}
>
{filters.map((f) => (
<Chip
key={f.key}
label={f.label}
selected={effectiveFilter === f.key}
onPress={() => setFilter(f.key)}
/>
))}
</ScrollView>
{(directory.errors.length > 0 || cloud.error || error) && (
<Text tone="muted" style={{ paddingHorizontal: space.x5 }}>
{t.cloudListUnavailable}
{error ? ` (${error})` : ''}
</Text>
)}
</>
}
ListEmptyComponent={
snapshot.roster === undefined ? undefined : (
<View style={{ padding: space.x6, alignItems: 'center' }}>
<Text tone="muted" style={{ textAlign: 'center' }}>
{searching ? t.searchNoMatch(query.trim()) : t.noBots}
</Text>
</View>
directory.busy ||
(saved && !snapshot.roster && snapshot.connection.status === 'connecting') ? (
<ActivityIndicator />
) : (
<Text tone="muted" style={{ padding: space.x6 }}>
{query ? t.searchNoMatch(query.trim()) : t.cloudChannels.empty}
</Text>
)
}
renderItem={({ item }) => {
if (item.kind === 'section')
return (
<SectionHeader
name={item.id === PINNED_SECTION ? t.pinnedSection : (item.name ?? '')}
count={item.count}
needsYou={item.needsYou}
unread={item.unread}
collapsed={item.collapsed}
onToggle={() => sectionCollapse.toggle(saved.hostId, item.id)}
/>
);
const row = item.row;
return (
renderItem={({ item }) =>
item.type === 'computer' ? (
<ConversationRow
row={row}
now={now}
onPress={() => openConversation(router, row)}
row={item.conversation}
now={new Date()}
onPress={() => openConversation(router, item.conversation, saved?.hostId)}
/>
) : (
<CloudConversationRow
row={item.conversation}
disabled={opening || cloud.busy || directory.busy}
onPress={() => void openCloud(item.conversation)}
/>
);
}}
)
}
/>
</HomeHeader>
</HostDrawerEdge>
Expand Down
Loading
Loading