Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions apps/mobile/app/devices/index.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
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';
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 <HomeRosterProvider key={accountGeneration}><View style={styles.home}>
return <View style={styles.home}>
<MobileHome />
<SessionHeaderNativeBlur height={headerHeight} />
</View></HomeRosterProvider>;
</View>;
}

const styles = StyleSheet.create({ home: { flex: 1 } });
44 changes: 35 additions & 9 deletions apps/mobile/src/__tests__/HomeTeammatesSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -20,10 +21,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;
Expand All @@ -36,11 +39,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,
Expand All @@ -54,11 +60,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(); vi.useFakeTimers(); roster.error = 'LIST_FAILED'; });
beforeEach(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); node = document.createElement('div'); root = createRoot(node); vi.clearAllMocks(); h.cloudActor = null; vi.useFakeTimers(); roster.error = 'LIST_FAILED'; });
afterEach(async () => { await act(async () => root.unmount()); vi.useRealTimers(); });
const render = (refreshKey: number) => act(async () => root.render(el(HomeTeammatesSection, {
const render = (refreshKey: number, overrides: Partial<Parameters<typeof HomeTeammatesSection>[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<HTMLElement>(`[data-testid="${id}"]`);

Expand Down Expand Up @@ -100,3 +107,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');
});
21 changes: 21 additions & 0 deletions apps/mobile/src/__tests__/sessionListDrawerUpdates.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,19 @@ vi.mock("react-native", async () => {
testID?: string;
onPress?: () => void;
accessibilityState?: { selected?: boolean };
pointerEvents?: string;
accessibilityElementsHidden?: boolean;
importantForAccessibility?: string;
ref?: import("react").Ref<HTMLDivElement>;
};
const View = ({
children,
testID,
onPress,
accessibilityState,
pointerEvents,
accessibilityElementsHidden,
importantForAccessibility,
ref,
}: Props) =>
createElement(
Expand All @@ -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,
Expand Down Expand Up @@ -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(<SessionListDrawer {...props} />));
const content = () => container.querySelector('[data-testid="sessionDrawer.panel"]')!.firstElementChild!;
expect(content().getAttribute('data-pointer-events')).toBe('auto');
await act(async () => root.render(<SessionListDrawer {...props} open={false} />));
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));
Expand All @@ -208,6 +225,10 @@ describe('shared Home drawer host', () => {
await act(async () => root.render(<SessionListDrawer {...props} />));
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(<SessionListDrawer {...props} open={false} />));
await act(async () => vi.advanceTimersByTime(motionDuration.exit));
Expand Down
17 changes: 15 additions & 2 deletions apps/mobile/src/__tests__/useTeammateNavigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useTeammateNavigation>;
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<typeof import('@/utils/useGuardedPush').useGuardedPush> }) { result = useTeammateNavigation(push); return null; }
async function render(push?: ReturnType<typeof import('@/utils/useGuardedPush').useGuardedPush>) { 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;
Expand All @@ -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) });
Expand Down
14 changes: 9 additions & 5 deletions apps/mobile/src/session/HomeSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -462,7 +463,9 @@ export function MobileHome(props: MobileHomeProps) {
const { accountGeneration } = useAuth();
return <RemoteSessionStoreSubscriptionGate enabled={screenFocused}>
<ActiveHomeSession.Provider value={props.currentSessionId}>
<HomeScreenContent key={accountGeneration} {...props} />
<HomeRosterProvider key={accountGeneration}>
<HomeScreenContent {...props} />
</HomeRosterProvider>
</ActiveHomeSession.Provider>
</RemoteSessionStoreSubscriptionGate>;
}
Expand Down Expand Up @@ -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,先把列表画出来(消除首屏强制
Expand Down Expand Up @@ -2973,13 +2976,14 @@ function HomeScreenContent({ active = true, width, newSessionInSystemBar = false
}}
sections={sections}
ListHeaderComponent={<>
{embedded ? null : <HomeTeammatesSection context={teammatesSectionContext} active={active} refreshKey={teammatesRefreshKey}
<HomeTeammatesSection context={teammatesSectionContext} active={active} refreshKey={teammatesRefreshKey}
Comment thread
dashhuang marked this conversation as resolved.
push={guardedPush}
// Independent of the computer-connection guide: server group chats and cloud companions need no computer.
visible={indexedSearch.activeFilterCount === 0 && statusFilter !== 'archived'}
query={searchQuery}
// Shared group and project blocks draw their own full-width top line.
showFooter={sharedRows.length === 0 && !isBlockHomeRow(sections[0]?.data[0])}
runDisclosure={runDisclosure} prepareDisclosure={prepareListDisclosure} />}
runDisclosure={runDisclosure} prepareDisclosure={prepareListDisclosure} />
Comment thread
dashhuang marked this conversation as resolved.
{sharedRows.length > 0 ? <DisclosureItem clip style={styles.projectGroup} testID="home.sharedGroup">
<Pressable
accessibilityRole="button"
Expand Down
14 changes: 9 additions & 5 deletions apps/mobile/src/session/HomeTeammatesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function useHomeTeammatesSectionContext() {
return useMemo(() => ({ 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<typeof useHomeTeammatesSectionContext>;
/** The home is the screen in front. */
active: boolean;
Expand All @@ -70,28 +70,32 @@ 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<typeof useGuardedPush>;
runDisclosure(apply: () => void): void;
prepareDisclosure(): void;
}) {
const { navigation, route, shared } = context;
if (!shared) return null;
return <NavigationContext.Provider value={navigation}><NavigationRouteContext.Provider value={route}>
<SectionContent shared={shared} active={active} refreshKey={refreshKey} visible={visible} query={query} showFooter={showFooter}
runDisclosure={runDisclosure} prepareDisclosure={prepareDisclosure} />
push={push} runDisclosure={runDisclosure} prepareDisclosure={prepareDisclosure} />
</NavigationRouteContext.Provider></NavigationContext.Provider>;
}

function SectionContent({ shared, active, refreshKey, visible, query, showFooter, runDisclosure, prepareDisclosure }: {
function SectionContent({ shared, active, refreshKey, visible, query, showFooter, push: hostPush, runDisclosure, prepareDisclosure }: {
shared: NonNullable<ReturnType<typeof useHomeRoster>>;
active: boolean; refreshKey: number; visible: boolean; query: string; showFooter: boolean;
push?: ReturnType<typeof useGuardedPush>;
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);
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/session/SessionListDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,10 @@ export function SessionListDrawer({ currentSessionId, persistent = false, newSes
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.panel, { paddingLeft: insets.left, paddingTop: topInset ?? insets.top, width: panelWidth },
!persistent && panelStyle]} testID="sessionDrawer.panel">
<View ref={homeContentRef} style={styles.content}>
{/* 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. */}
<View ref={homeContentRef} style={styles.content} pointerEvents={open ? 'auto' : 'none'}
accessibilityElementsHidden={!open} importantForAccessibility={open ? 'auto' : 'no-hide-descendants'}>
<MobileHome newSessionActionRef={newSessionActionRef} width={width} currentSessionId={currentSessionId}
newSessionInSystemBar={newSessionInSystemBar} newSessionInHeader={!persistent}
onSelectSession={onSelectSession} runNavigation={runNavigation} />
Expand Down
Loading
Loading