From c53c65d56c544e8ac3785497b28e31856d48a8e3 Mon Sep 17 00:00:00 2001
From: Dash <125997726+dashhuang@users.noreply.github.com>
Date: Sun, 25 Oct 2026 09:47:04 +1300
Subject: [PATCH 1/3] =?UTF-8?q?fix(mobile):=20=E5=9C=A8=E5=8F=8C=E6=A0=8F?=
=?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=88=97=E8=A1=A8=E9=A1=B6=E9=83=A8=E6=98=BE?=
=?UTF-8?q?=E7=A4=BA=E4=BC=99=E4=BC=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: Dash <125997726+dashhuang@users.noreply.github.com>
---
apps/mobile/app/devices/index.tsx | 7 ++-----
apps/mobile/src/session/HomeSurface.tsx | 13 ++++++++-----
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/apps/mobile/app/devices/index.tsx b/apps/mobile/app/devices/index.tsx
index 1884e12f905..b44f95bee3e 100644
--- a/apps/mobile/app/devices/index.tsx
+++ b/apps/mobile/app/devices/index.tsx
@@ -1,6 +1,4 @@
import { StyleSheet, View } from 'react-native';
-import { useAuth } from '@/auth/AuthContext';
-import { HomeRosterProvider } from '@/session/HomeRosterContext';
import { MobileHome } from '@/session/HomeSurface';
import { SessionHeaderNativeBlur } from '@/session/SessionHeaderNativeControls';
import { useHomeMode } from '@/session/useHomeMode';
@@ -8,15 +6,14 @@ import { useOptionalHeaderHeight } from '@/session/useOptionalHeaderHeight';
/** One home, as on desktop: teammates are the section at the top of the task list. */
export default function HomeScreen() {
- const { accountGeneration } = useAuth();
const navigation = useHomeMode();
const headerHeight = useOptionalHeaderHeight();
// The teammate section's stored fold is part of the first frame.
if (!navigation.hydrated) return null;
- return
+ return
- ;
+ ;
}
const styles = StyleSheet.create({ home: { flex: 1 } });
diff --git a/apps/mobile/src/session/HomeSurface.tsx b/apps/mobile/src/session/HomeSurface.tsx
index 43f69a73947..c5ac971dcf2 100644
--- a/apps/mobile/src/session/HomeSurface.tsx
+++ b/apps/mobile/src/session/HomeSurface.tsx
@@ -69,6 +69,7 @@ import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { useAuth } from '@/auth/AuthContext';
import { HomeTeammatesSection, useHomeTeammatesSectionContext } from './HomeTeammatesSection';
+import { HomeRosterProvider } from './HomeRosterContext';
import { createDisclosureListCell, DisclosureGroupView, DisclosureItem, ListDisclosureScope, useDisclosurePrepare, useListDisclosureTransition } from './listDisclosureTransition';
import { useGuardedPush } from '@/utils/useGuardedPush';
import { useHomeMenuFadeTiming } from '@/session/homeMenuFadeTiming';
@@ -462,7 +463,9 @@ export function MobileHome(props: MobileHomeProps) {
const { accountGeneration } = useAuth();
return
-
+
+
+
;
}
@@ -1315,11 +1318,11 @@ function HomeScreenContent({ active = true, width, newSessionInSystemBar = false
const homeRoster = teammatesSectionContext.shared;
const refreshHome = useCallback(() => {
void loadHome({ visible: true });
- if (embedded || !homeRoster) return;
+ if (!homeRoster) return;
void homeRoster.roster.refresh();
if (homeRoster.groups.supported) void homeRoster.groups.refresh();
setTeammatesRefreshKey(key => key + 1);
- }, [embedded, homeRoster, loadHome]);
+ }, [homeRoster, loadHome]);
loadHomeRef.current = loadHome;
// 冷启动先画缓存:上次 loadHome 成功的设备+会话快照种入 store,先把列表画出来(消除首屏强制
@@ -2973,13 +2976,13 @@ function HomeScreenContent({ active = true, width, newSessionInSystemBar = false
}}
sections={sections}
ListHeaderComponent={<>
- {embedded ? null : }
+ runDisclosure={runDisclosure} prepareDisclosure={prepareListDisclosure} />
{sharedRows.length > 0 ?
Date: Sun, 25 Oct 2026 10:00:21 +1300
Subject: [PATCH 2/3] =?UTF-8?q?fix(mobile):=20=E4=BC=99=E4=BC=B4=E8=B7=B3?=
=?UTF-8?q?=E8=BD=AC=E7=AD=89=E5=BE=85=E4=BB=BB=E5=8A=A1=E6=8A=BD=E5=B1=89?=
=?UTF-8?q?=E5=85=B3=E9=97=AD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: Dash <125997726+dashhuang@users.noreply.github.com>
---
.../__tests__/HomeTeammatesSection.test.tsx | 44 +++++++++++++++----
.../__tests__/useTeammateNavigation.test.tsx | 17 ++++++-
apps/mobile/src/session/HomeSurface.tsx | 1 +
.../src/session/HomeTeammatesSection.tsx | 14 +++---
.../src/session/useTeammateNavigation.ts | 7 ++-
5 files changed, 65 insertions(+), 18 deletions(-)
diff --git a/apps/mobile/src/__tests__/HomeTeammatesSection.test.tsx b/apps/mobile/src/__tests__/HomeTeammatesSection.test.tsx
index 2f8d8575522..8641a1fe22e 100644
--- a/apps/mobile/src/__tests__/HomeTeammatesSection.test.tsx
+++ b/apps/mobile/src/__tests__/HomeTeammatesSection.test.tsx
@@ -2,7 +2,8 @@
import { act, createContext, createElement as el } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
-const h = vi.hoisted(() => ({ cloudRefresh: vi.fn(async () => {}) }));
+const h = vi.hoisted(() => ({ cloudRefresh: vi.fn(async () => {}), push: vi.fn(),
+ cloudActor: null as null | { id: string }, createOptions: null as any }));
vi.mock('react-native', () => ({
View: ({ children, testID }: any) => el('div', { 'data-testid': testID }, children),
ScrollView: ({ children, testID }: any) => el('div', { 'data-testid': testID }, children),
@@ -19,10 +20,12 @@ vi.mock('lucide-react-native', () => ({ ChevronDown: () => null, ChevronRight: (
vi.mock('@/auth/AuthContext', () => ({ useAuth: () => ({ user: { id: 'owner' } }) }));
vi.mock('@/components/AppText', () => ({ Text: ({ children, testID }: any) => el('span', { 'data-testid': testID }, children) }));
vi.mock('@/components/RemoteCompanionAvatar', () => ({ RemoteCompanionAvatar: () => null }));
-vi.mock('@/chat/cloudCompanion', () => ({ CLOUD_ROW_REVISION: 'cloud', cloudActorForRow: () => null, cloudCompanionRows: (items: unknown) => items }));
+vi.mock('@/chat/cloudCompanion', () => ({ CLOUD_ROW_REVISION: 'cloud', cloudActorForRow: () => h.cloudActor, cloudCompanionRows: (items: unknown) => items }));
+vi.mock('@/session/teammateNavigation', () => ({ cloudCompanionRoute: () => '/cloud-chat' }));
+vi.mock('@/session/botGroupNavigation', () => ({ botGroupRoute: () => '/group-chat' }));
vi.mock('@/chat/useCloudCompanion', () => ({ useCloudCompanionHome: () => ({ supported: true, actors: [], deviceNames: {}, refresh: h.cloudRefresh }) }));
vi.mock('@/device-link/remoteResourceCache', () => ({ isRemoteResourceUnread: () => false, remoteResourceCacheRevision: () => 0, subscribeRemoteResourceCache: () => () => {} }));
-vi.mock('@/utils/useGuardedPush', () => ({ useGuardedPush: () => () => {} }));
+vi.mock('@/utils/useGuardedPush', () => ({ useGuardedPush: () => h.push }));
vi.mock('@/theme', async () => {
const tokens = await import('@/theme/tokens');
const colors = tokens.lightColors;
@@ -35,11 +38,14 @@ vi.mock('@/session/CompanionPresenceRing', () => ({ CompanionPresenceRing: () =>
vi.mock('@/session/HomeRosterContext', () => ({ useHomeRoster: () => null }));
vi.mock('@/session/HomeListVisuals', () => ({ homeListStyles: () => ({}) }));
vi.mock('@/session/listDisclosureTransition', () => ({ DisclosureItem: ({ children, testID }: any) => el('div', { 'data-testid': testID }, children) }));
-vi.mock('@/session/TeammateCreateController', () => ({ useTeammateCreateController: () => ({
- renderTrigger: (trigger: unknown) => trigger, press: () => {}, label: 'create', sheets: null }) }));
+vi.mock('@/session/TeammateCreateController', () => ({ useTeammateCreateController: (options: any) => {
+ h.createOptions = options;
+ return { renderTrigger: (trigger: unknown) => trigger, press: () => {}, label: 'create', sheets: null };
+} }));
vi.mock('@/session/TeammateList', () => ({
- useTeammateEntries: (items: any[]) => items.map(row => ({ kind: 'bot', row })),
- useTeammateRowRenderer: () => (entry: any) => el('div', { key: entry.row.key }, entry.row.item.display.title),
+ useTeammateEntries: (items: any[], groups: any[] = []) => [...items.map(row => ({ kind: 'bot', row })), ...groups.map(row => ({ kind: 'group', row }))],
+ useTeammateRowRenderer: (options: any) => (entry: any) => el('button', { key: entry.row.key,
+ 'data-testid': `row.${entry.kind}`, onClick: () => entry.kind === 'bot' ? options.onSelect(entry.row) : options.groups.onSelect(entry.row) }, entry.row.item.display.title),
}));
vi.mock('@/session/useTeammateNavigation', () => ({ useTeammateNavigation: () => ({
owner: 'owner', teammatesCollapsed: false, lastTeammate: null,
@@ -52,11 +58,12 @@ const roster = { items: [item], createTargets: [], groupTargets: [], loading: fa
isOnline: () => true, connectionState: undefined, refresh: vi.fn(async () => {}) };
const groups = { items: [], supported: true, refreshing: false, error: null, isOnline: () => true, refresh: vi.fn(async () => {}) };
let root: Root; let node: HTMLDivElement;
-beforeEach(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); node = document.createElement('div'); root = createRoot(node); vi.clearAllMocks(); });
+beforeEach(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); node = document.createElement('div'); root = createRoot(node); vi.clearAllMocks(); h.cloudActor = null; });
afterEach(async () => { await act(async () => root.unmount()); });
-const render = (refreshKey: number) => act(async () => root.render(el(HomeTeammatesSection, {
+const render = (refreshKey: number, overrides: Partial[0]> = {}) => act(async () => root.render(el(HomeTeammatesSection, {
context: { navigation: null, route: null, shared: { roster, groups } } as any, active: true, refreshKey,
visible: true, query: '', showFooter: false, runDisclosure: (apply: () => void) => apply(), prepareDisclosure: () => {},
+ ...overrides,
})));
const byId = (id: string) => node.querySelector(`[data-testid="${id}"]`);
@@ -79,3 +86,22 @@ it('re-reads cloud companions on each manual home refresh', async () => {
await render(1);
expect(h.cloudRefresh).toHaveBeenCalledTimes(1);
});
+
+// PR #7079: unit-tier component coverage of the cloud/group routes that bypass the teammate hook.
+// Existing coverage only refreshed the roster. A deferred host push must prevent navigation until
+// drawer close; the creation receipt is a separate entry after its own sheet has already closed.
+it.each(['cloud', 'group', 'created-group'] as const)('defers the %s route until the drawer closes', async kind => {
+ let routeAfterClose: (() => void) | undefined;
+ const bot = { ...item, item: { ...item.item, revision: 'cloud' } };
+ const group = { ...item, key: 'host:group', item: { ...item.item, ref: { ...item.item.ref, id: 'group' } } };
+ h.cloudActor = { id: 'actor' };
+ await render(0, { push: href => { routeAfterClose = () => h.push(href); },
+ context: { navigation: null, route: null, shared: { roster: { ...roster, items: [bot] }, groups: { ...groups, items: [group] } } } as any });
+ await act(async () => {
+ if (kind === 'created-group') h.createOptions.onGroupCreated(item.host, 'group');
+ else byId(`row.${kind === 'cloud' ? 'bot' : 'group'}`)!.click();
+ });
+ expect(h.push).not.toHaveBeenCalled();
+ routeAfterClose?.();
+ expect(h.push).toHaveBeenCalledExactlyOnceWith(kind === 'cloud' ? '/cloud-chat' : '/group-chat');
+});
diff --git a/apps/mobile/src/__tests__/useTeammateNavigation.test.tsx b/apps/mobile/src/__tests__/useTeammateNavigation.test.tsx
index c35334538a1..e6979da9dc1 100644
--- a/apps/mobile/src/__tests__/useTeammateNavigation.test.tsx
+++ b/apps/mobile/src/__tests__/useTeammateNavigation.test.tsx
@@ -26,8 +26,8 @@ const teammate = { key: 'mac:writer', host: { deviceId: 'mac', deviceName: 'My M
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let root: Root | undefined;
let result: ReturnType;
-function Probe() { result = useTeammateNavigation(); return null; }
-async function render() { root ??= createRoot(document.createElement('div')); await act(async () => root!.render(createElement(Probe))); }
+function Probe({ push }: { push?: ReturnType }) { result = useTeammateNavigation(push); return null; }
+async function render(push?: ReturnType) { root ??= createRoot(document.createElement('div')); await act(async () => root!.render(createElement(Probe, { push }))); }
let serial = 0;
beforeEach(() => {
vi.clearAllMocks(); h.sessions = []; h.routes = []; h.disk.clear(); h.accountGeneration = ++serial;
@@ -37,6 +37,19 @@ beforeEach(() => {
});
afterEach(() => { act(() => root?.unmount()); root = undefined; vi.useRealTimers(); });
describe('header/home shared navigation', () => {
+ // PR #7079: the drawer must unmount before routing. Existing tests only covered immediate home
+ // pushes; one teammate or creation receipt with a deferred host push reproduces the regression.
+ // Unit tier, real navigation hook; the queued route must survive the source hook's unmount.
+ it.each(['existing', 'created'] as const)('waits for the drawer to close before opening an %s teammate', async (kind) => {
+ let routeAfterClose: (() => void) | undefined;
+ await render(href => { routeAfterClose = () => h.push(href); });
+ await act(async () => kind === 'existing' ? result.openTeammate(teammate) : result.openCreatedTeammate(teammate.host, teammate.item.ref));
+ expect(h.push).not.toHaveBeenCalled();
+ expect(result.lastTeammate).toEqual(teammateIdentity(teammate));
+ await act(async () => root!.unmount()); root = undefined;
+ routeAfterClose?.();
+ expect(h.push).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ pathname: '/resources/[collectionId]/[resourceId]' }));
+ });
it('restores the remembered teammate and section fold, and returning home keeps them', async () => {
const owner = homeNavigationOwner(h.user);
await saveHomeNavigationPreferences(owner, { teammatesCollapsed: true, lastTeammate: teammateIdentity(teammate) });
diff --git a/apps/mobile/src/session/HomeSurface.tsx b/apps/mobile/src/session/HomeSurface.tsx
index c5ac971dcf2..80f4b8b5db9 100644
--- a/apps/mobile/src/session/HomeSurface.tsx
+++ b/apps/mobile/src/session/HomeSurface.tsx
@@ -2977,6 +2977,7 @@ function HomeScreenContent({ active = true, width, newSessionInSystemBar = false
sections={sections}
ListHeaderComponent={<>
({ navigation, route, shared }), [navigation, route, shared]);
}
-export function HomeTeammatesSection({ context, active, refreshKey, visible, query, showFooter, runDisclosure, prepareDisclosure }: {
+export function HomeTeammatesSection({ context, active, refreshKey, visible, query, showFooter, push, runDisclosure, prepareDisclosure }: {
context: ReturnType;
/** The home is the screen in front. */
active: boolean;
@@ -69,6 +69,8 @@ export function HomeTeammatesSection({ context, active, refreshKey, visible, que
query: string;
/** Full-width hairline under the section, unless the next block draws its own top line. */
showFooter: boolean;
+ /** Embedded lists close their modal drawer before dispatching the route. */
+ push?: ReturnType;
runDisclosure(apply: () => void): void;
prepareDisclosure(): void;
}) {
@@ -76,21 +78,23 @@ export function HomeTeammatesSection({ context, active, refreshKey, visible, que
if (!shared) return null;
return
+ push={push} runDisclosure={runDisclosure} prepareDisclosure={prepareDisclosure} />
;
}
-function SectionContent({ shared, active, refreshKey, visible, query, showFooter, runDisclosure, prepareDisclosure }: {
+function SectionContent({ shared, active, refreshKey, visible, query, showFooter, push: hostPush, runDisclosure, prepareDisclosure }: {
shared: NonNullable>;
active: boolean; refreshKey: number; visible: boolean; query: string; showFooter: boolean;
+ push?: ReturnType;
runDisclosure(apply: () => void): void; prepareDisclosure(): void;
}) {
const { t, i18n } = useTranslation();
const { colors } = useTheme();
const styles = useThemedStyles(makeStyles);
const list = useThemedStyles(homeListStyles);
- const push = useGuardedPush();
- const navigation = useTeammateNavigation();
+ const guardedPush = useGuardedPush();
+ const push = hostPush ?? guardedPush;
+ const navigation = useTeammateNavigation(push);
const focused = useIsFocused() && active;
const { roster, groups } = shared;
const cloud = useCloudCompanionHome(focused && visible);
diff --git a/apps/mobile/src/session/useTeammateNavigation.ts b/apps/mobile/src/session/useTeammateNavigation.ts
index 06ebd5238ce..85214de7dd0 100644
--- a/apps/mobile/src/session/useTeammateNavigation.ts
+++ b/apps/mobile/src/session/useTeammateNavigation.ts
@@ -12,11 +12,14 @@ import { remoteSessionStore } from './remoteSessionStore';
import { readRemoteCollectionCache, writeRemoteCollectionCache } from '@/device-link/remoteResourceAvailability';
/** Push keeps the current chat/composer mounted; known teammates validate in the destination. */
-export function useTeammateNavigation() {
+export function useTeammateNavigation(hostPush?: ReturnType) {
const preferences = useHomeMode();
const { user, accountGeneration } = useAuth();
const { i18n } = useTranslation();
- const push = useGuardedPush();
+ const guardedPush = useGuardedPush();
+ // Defer only the final dispatch: selection and its account/mount guards must finish while the
+ // drawer still exists. The host then closes/unmounts the drawer before executing the push.
+ const push = hostPush ?? guardedPush;
const router = useRouter();
const navigation = useNavigation();
const current = useRef(accountGeneration); current.current = accountGeneration;
From 2ae9c7c552c280bc56dc9d6814bca5dd376b2573 Mon Sep 17 00:00:00 2001
From: Dash <125997726+dashhuang@users.noreply.github.com>
Date: Sun, 25 Oct 2026 10:34:34 +1300
Subject: [PATCH 3/3] =?UTF-8?q?fix(mobile):=20=E5=85=B3=E9=97=AD=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=E6=8A=BD=E5=B1=89=E6=97=B6=E5=81=9C=E7=94=A8=E5=88=97?=
=?UTF-8?q?=E8=A1=A8=E6=93=8D=E4=BD=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: Dash <125997726+dashhuang@users.noreply.github.com>
---
.../sessionListDrawerUpdates.test.tsx | 21 +++++++++++++++++++
apps/mobile/src/session/SessionListDrawer.tsx | 5 ++++-
2 files changed, 25 insertions(+), 1 deletion(-)
diff --git a/apps/mobile/src/__tests__/sessionListDrawerUpdates.test.tsx b/apps/mobile/src/__tests__/sessionListDrawerUpdates.test.tsx
index 68af2e8698e..0b697181d22 100644
--- a/apps/mobile/src/__tests__/sessionListDrawerUpdates.test.tsx
+++ b/apps/mobile/src/__tests__/sessionListDrawerUpdates.test.tsx
@@ -26,6 +26,9 @@ vi.mock("react-native", async () => {
testID?: string;
onPress?: () => void;
accessibilityState?: { selected?: boolean };
+ pointerEvents?: string;
+ accessibilityElementsHidden?: boolean;
+ importantForAccessibility?: string;
ref?: import("react").Ref;
};
const View = ({
@@ -33,6 +36,9 @@ vi.mock("react-native", async () => {
testID,
onPress,
accessibilityState,
+ pointerEvents,
+ accessibilityElementsHidden,
+ importantForAccessibility,
ref,
}: Props) =>
createElement(
@@ -41,6 +47,9 @@ vi.mock("react-native", async () => {
"data-testid": testID,
onClick: onPress,
"aria-selected": accessibilityState?.selected,
+ "data-pointer-events": pointerEvents,
+ "data-accessibility-hidden": accessibilityElementsHidden,
+ "data-accessibility-importance": importantForAccessibility,
ref,
},
children,
@@ -191,8 +200,16 @@ describe('shared Home drawer host', () => {
it('unmounts the invisible blocker even when the animation never calls completion', async () => {
native.reduceMotion = false;
await act(async () => root.render());
+ const content = () => container.querySelector('[data-testid="sessionDrawer.panel"]')!.firstElementChild!;
+ expect(content().getAttribute('data-pointer-events')).toBe('auto');
await act(async () => root.render());
expect(container.querySelector('[data-testid="sessionDrawer.overlay"]')).not.toBeNull();
+ // PR #7079: RN's native input/accessibility contract must stop a second selection during
+ // exit; otherwise it updates lastTeammate while the coordinator retains the first route.
+ // Existing close-settlement coverage only checked eventual unmount. Unit tier, fake clock.
+ expect(content().getAttribute('data-pointer-events')).toBe('none');
+ expect(content().getAttribute('data-accessibility-hidden')).toBe('true');
+ expect(content().getAttribute('data-accessibility-importance')).toBe('no-hide-descendants');
expect(props.onClosed).not.toHaveBeenCalled();
// withTiming deliberately never delivers a completion in this test driver.
await act(async () => vi.advanceTimersByTime(motionDuration.exit));
@@ -208,6 +225,10 @@ describe('shared Home drawer host', () => {
await act(async () => root.render());
await act(async () => vi.advanceTimersByTime(motionDuration.exit));
expect(container.querySelector('[data-testid="sessionDrawer.overlay"]')).not.toBeNull();
+ const content = container.querySelector('[data-testid="sessionDrawer.panel"]')!.firstElementChild!;
+ expect(content.getAttribute('data-pointer-events')).toBe('auto');
+ expect(content.getAttribute('data-accessibility-hidden')).toBe('false');
+ expect(content.getAttribute('data-accessibility-importance')).toBe('auto');
expect(props.onClosed).not.toHaveBeenCalled();
await act(async () => root.render());
await act(async () => vi.advanceTimersByTime(motionDuration.exit));
diff --git a/apps/mobile/src/session/SessionListDrawer.tsx b/apps/mobile/src/session/SessionListDrawer.tsx
index 87152cb8c84..2bc8c8fb34b 100644
--- a/apps/mobile/src/session/SessionListDrawer.tsx
+++ b/apps/mobile/src/session/SessionListDrawer.tsx
@@ -203,7 +203,10 @@ export function SessionListDrawer({ currentSessionId, persistent = false, newSes
-
+ {/* The host keeps the first navigation intent. Stop all list input during exit so a
+ second selection cannot change preferences while its route is discarded. */}
+