From 96f45e89c141e405edbb2eac805538ee30044bf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 1 Sep 2026 16:46:24 +0200 Subject: [PATCH 1/6] fix(mobile): repair session layouts and localized copy --- .../agent-chat/[session-id].mounted.test.tsx | 14 +- .../src/app/(app)/agent-chat/[session-id].tsx | 5 +- .../agents/child-session-section.tsx | 8 +- .../agents/live-session-list-empty-state.tsx | 64 ++++ .../agents/new-session-configure-form.test.ts | 41 +++ .../agents/new-session-configure-form.tsx | 36 +- .../agents/new-session-screen-body.tsx | 3 + .../platform-filter-modal.mounted.test.tsx | 14 +- .../agents/platform-filter-modal.tsx | 44 ++- .../agents/session-detail-content.test.ts | 33 +- .../agents/session-detail-content.tsx | 28 +- .../agents/session-detail-queue.test.ts | 22 +- .../session-list-screen.mounted.test.tsx | 161 ++++++++- .../components/agents/session-list-screen.tsx | 39 +-- .../agents/suggest-tool-card.test.ts | 323 ++++++++++-------- .../components/agents/suggest-tool-card.tsx | 76 ++++- .../agents/suggestion-card-state.ts | 13 + .../agents/tool-card-display.test.ts | 31 +- .../components/agents/tool-card-display.ts | 28 +- .../agents/tool-part-detail-body.test.ts | 23 +- .../agents/tool-part-detail-body.tsx | 8 +- .../agents/tool-part-renderer.test.ts | 24 ++ .../app-unlock-screen.layout.mounted.test.tsx | 31 ++ .../src/components/app-unlock-screen.tsx | 52 +-- .../context-control.mounted.test.tsx | 1 + .../mobile/src/components/context-control.tsx | 22 +- .../home/agent-sessions-section.tsx | 14 +- apps/mobile/src/components/home/greeting.tsx | 2 +- .../home/home-screen.mounted.test.tsx | 54 ++- .../src/components/home/home-screen.tsx | 2 + .../message-reaction-picker-sheet.tsx | 10 +- .../discussion/reaction-picker-sheet.tsx | 7 +- .../pr-review/pr-form-sheet-chrome.tsx | 1 + .../pr-review/pr-review-overview.tsx | 25 +- ...r-review-reconnect-notice.mounted.test.tsx | 128 +++++++ .../pr-review/pr-review-reconnect-notice.tsx | 26 +- .../profile-credits-card.mounted.test.tsx | 10 +- .../src/components/profile-credits-card.tsx | 14 +- .../components/screen-header.mounted.test.tsx | 35 +- apps/mobile/src/components/screen-header.tsx | 49 ++- .../src/components/share/share-gate-sheet.tsx | 7 +- .../sheet-header-title.mounted.test.tsx | 19 +- .../components/sheet-header.mounted.test.tsx | 19 +- apps/mobile/src/components/sheet-header.tsx | 22 +- apps/mobile/src/i18n/label-reference.test.ts | 86 +++++ apps/mobile/src/i18n/locales/af.json | 12 +- apps/mobile/src/i18n/locales/am.json | 4 +- apps/mobile/src/i18n/locales/ar.json | 4 +- apps/mobile/src/i18n/locales/az.json | 14 +- apps/mobile/src/i18n/locales/be.json | 24 +- apps/mobile/src/i18n/locales/bg.json | 20 +- apps/mobile/src/i18n/locales/bn.json | 6 +- apps/mobile/src/i18n/locales/bs.json | 26 +- apps/mobile/src/i18n/locales/ca.json | 12 +- apps/mobile/src/i18n/locales/ckb.json | 4 +- apps/mobile/src/i18n/locales/cs.json | 10 +- apps/mobile/src/i18n/locales/cy.json | 12 +- apps/mobile/src/i18n/locales/da.json | 8 +- apps/mobile/src/i18n/locales/de.json | 6 +- apps/mobile/src/i18n/locales/el.json | 18 +- apps/mobile/src/i18n/locales/en.json | 2 +- apps/mobile/src/i18n/locales/es.json | 10 +- apps/mobile/src/i18n/locales/et.json | 8 +- apps/mobile/src/i18n/locales/eu.json | 14 +- apps/mobile/src/i18n/locales/fa.json | 4 +- apps/mobile/src/i18n/locales/fi.json | 12 +- apps/mobile/src/i18n/locales/fil.json | 14 +- apps/mobile/src/i18n/locales/fr.json | 4 +- apps/mobile/src/i18n/locales/ga.json | 14 +- apps/mobile/src/i18n/locales/gl.json | 18 +- apps/mobile/src/i18n/locales/gu.json | 4 +- apps/mobile/src/i18n/locales/ha.json | 28 +- apps/mobile/src/i18n/locales/he.json | 4 +- apps/mobile/src/i18n/locales/hi.json | 4 +- apps/mobile/src/i18n/locales/hr.json | 22 +- apps/mobile/src/i18n/locales/ht.json | 20 +- apps/mobile/src/i18n/locales/hu.json | 16 +- apps/mobile/src/i18n/locales/hy.json | 18 +- apps/mobile/src/i18n/locales/id.json | 8 +- apps/mobile/src/i18n/locales/ig.json | 14 +- apps/mobile/src/i18n/locales/is.json | 22 +- apps/mobile/src/i18n/locales/it.json | 6 +- apps/mobile/src/i18n/locales/ja.json | 4 +- apps/mobile/src/i18n/locales/ka.json | 4 +- apps/mobile/src/i18n/locales/kk.json | 14 +- apps/mobile/src/i18n/locales/km.json | 6 +- apps/mobile/src/i18n/locales/kn.json | 6 +- apps/mobile/src/i18n/locales/ko.json | 8 +- apps/mobile/src/i18n/locales/lo.json | 6 +- apps/mobile/src/i18n/locales/lt.json | 18 +- apps/mobile/src/i18n/locales/lv.json | 12 +- apps/mobile/src/i18n/locales/mg.json | 18 +- apps/mobile/src/i18n/locales/mi.json | 14 +- apps/mobile/src/i18n/locales/mk.json | 12 +- apps/mobile/src/i18n/locales/ml.json | 6 +- apps/mobile/src/i18n/locales/mn.json | 30 +- apps/mobile/src/i18n/locales/mr.json | 6 +- apps/mobile/src/i18n/locales/ms.json | 28 +- apps/mobile/src/i18n/locales/mt.json | 12 +- apps/mobile/src/i18n/locales/my.json | 6 +- apps/mobile/src/i18n/locales/nb.json | 12 +- apps/mobile/src/i18n/locales/ne.json | 6 +- apps/mobile/src/i18n/locales/nl.json | 10 +- apps/mobile/src/i18n/locales/om.json | 24 +- apps/mobile/src/i18n/locales/or.json | 4 +- apps/mobile/src/i18n/locales/pa.json | 4 +- apps/mobile/src/i18n/locales/pl.json | 24 +- apps/mobile/src/i18n/locales/ps.json | 4 +- apps/mobile/src/i18n/locales/pt-BR.json | 18 +- apps/mobile/src/i18n/locales/pt.json | 18 +- apps/mobile/src/i18n/locales/ro.json | 22 +- apps/mobile/src/i18n/locales/ru.json | 16 +- apps/mobile/src/i18n/locales/si.json | 4 +- apps/mobile/src/i18n/locales/sk.json | 10 +- apps/mobile/src/i18n/locales/sl.json | 10 +- apps/mobile/src/i18n/locales/so.json | 22 +- apps/mobile/src/i18n/locales/sq.json | 10 +- apps/mobile/src/i18n/locales/sr.json | 18 +- apps/mobile/src/i18n/locales/sv.json | 8 +- apps/mobile/src/i18n/locales/sw.json | 22 +- apps/mobile/src/i18n/locales/ta.json | 4 +- apps/mobile/src/i18n/locales/te.json | 4 +- apps/mobile/src/i18n/locales/th.json | 6 +- apps/mobile/src/i18n/locales/tr.json | 24 +- apps/mobile/src/i18n/locales/uk.json | 20 +- apps/mobile/src/i18n/locales/ur.json | 4 +- apps/mobile/src/i18n/locales/uz.json | 12 +- apps/mobile/src/i18n/locales/vi.json | 12 +- apps/mobile/src/i18n/locales/yo.json | 36 +- apps/mobile/src/i18n/locales/zh-Hans.json | 4 +- apps/mobile/src/i18n/locales/zh-Hant.json | 4 +- apps/mobile/src/i18n/locales/zu.json | 14 +- .../classify-pr-review-query-state.test.ts | 1 + .../pr-review/use-check-github-connection.ts | 24 ++ apps/mobile/vitest.pure.config.ts | 2 +- .../src/lib/github-pr-review/retry.test.ts | 68 +++- apps/web/src/lib/github-pr-review/retry.ts | 12 + tools/i18n/check-catalogs.mjs | 11 +- tools/i18n/check-catalogs.test.mjs | 50 ++- 139 files changed, 1904 insertions(+), 1004 deletions(-) create mode 100644 apps/mobile/src/components/agents/live-session-list-empty-state.tsx create mode 100644 apps/mobile/src/components/app-unlock-screen.layout.mounted.test.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-reconnect-notice.mounted.test.tsx create mode 100644 apps/mobile/src/lib/pr-review/use-check-github-connection.ts diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx index 6c6ddbfb9f..1e912dc7de 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx @@ -504,7 +504,7 @@ describe('SessionDetailScreen display scope', () => { }); it.each(['pending', 'INTERNAL_SERVER_ERROR', 'NOT_FOUND', 'UNAUTHORIZED'])( - 'keeps the %s header unresolved and read-only', + 'omits context labels from the %s header and preserves recovery actions', async state => { useLocalSearchParamsMock.mockReturnValue({ 'session-id': 'sess-1' }); queryState.data = null; @@ -512,10 +512,8 @@ describe('SessionDetailScreen display scope', () => { queryState.isError = state !== 'pending'; queryState.error = { data: { code: state } }; const renderer = await mountRoute(); - const label = renderer.root.find( - node => (node.type as string) === 'View' && propOf(node, 'accessibilityRole') === 'text' - ); - expect(propOf(label, 'accessibilityState')).toEqual({ busy: true }); + const header = renderer.root.findByType(ScreenHeader); + expect(propOf(header, 'context')).toBeUndefined(); expect(findByType(renderer.root, 'Text').flatMap(node => node.children)).not.toContain( 'Personal' ); @@ -991,7 +989,11 @@ describe.each([true, false])('SessionDetailScreen header return with history=%s' expect(findByType(renderer.root, code ? 'QueryError' : 'SessionSkeletonMessages')).toHaveLength( 1 ); - const back = findByType(renderer.root.findByType(ScreenHeader), 'Pressable').find( + const header = renderer.root.findByType(ScreenHeader); + const title = header.findByProps({ accessibilityRole: 'header' }); + expect(propOf(title, 'numberOfLines')).toBe(1); + expect(propOf(title, 'ellipsizeMode')).toBe('tail'); + const back = findByType(header, 'Pressable').find( node => propOf(node, 'accessibilityLabel') === 'Go back' ); act(() => { diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx index c7724bada2..c177f19167 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx @@ -24,7 +24,6 @@ import { buildTerminalErrorCopyText } from '@/components/agents/session-terminal import { performCopy } from '@/components/agents/use-message-copy'; import { InvalidRouteState } from '@/components/invalid-route-state'; import { QueryError } from '@/components/query-error'; -import { ContextControl } from '@/components/context-control'; import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; @@ -140,7 +139,7 @@ export default function SessionDetailScreen() { } + titleNumberOfLines={1} backFallback="/(app)/(tabs)/(2_agents)" headerRight={ } + titleNumberOfLines={1} backFallback="/(app)/(tabs)/(2_agents)" /> diff --git a/apps/mobile/src/components/agents/child-session-section.tsx b/apps/mobile/src/components/agents/child-session-section.tsx index 7d81e143c9..c5ab0d66fb 100644 --- a/apps/mobile/src/components/agents/child-session-section.tsx +++ b/apps/mobile/src/components/agents/child-session-section.tsx @@ -25,6 +25,7 @@ import { import { getChildSessionModelLabel } from './child-session-model'; import { ChildSessionModelLabel } from './child-session-model-label'; import { MessageErrorBoundary } from './message-error-boundary'; +import { partRendersContent } from './message-visibility'; import { isToolPart } from './part-types'; export { getTaskToolSessionId } from './child-session-card-state'; @@ -158,9 +159,14 @@ export function ChildSessionMessage({ ); } + const visibleParts = message.parts.filter(partRendersContent); + if (visibleParts.length === 0) { + return null; + } + return ( - {message.parts.map(p => { + {visibleParts.map(p => { if (isToolPart(p) && p.tool === 'task') { const nestedSessionId = getTaskToolSessionId(p); const nestedMessages = nestedSessionId ? getChildMessages(nestedSessionId) : []; diff --git a/apps/mobile/src/components/agents/live-session-list-empty-state.tsx b/apps/mobile/src/components/agents/live-session-list-empty-state.tsx new file mode 100644 index 0000000000..19c5cedcc4 --- /dev/null +++ b/apps/mobile/src/components/agents/live-session-list-empty-state.tsx @@ -0,0 +1,64 @@ +import { type Href, useRouter } from 'expo-router'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { getNewAgentSessionPath } from '@/components/agents/session-list-routes'; +import { EmptyState } from '@/components/empty-state'; +import { Button } from '@/components/ui/button'; +import { Bot, Plus } from '@/components/ui/icons'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +type LiveSessionListEmptyStateProps = { + organizationId: string | null; + tabBarHeight: number; +}; + +export function LiveSessionListEmptyState({ + organizationId, + tabBarHeight, +}: Readonly) { + const router = useRouter(); + const colors = useThemeColors(); + const { t } = useTranslation(); + const { top } = useSafeAreaInsets(); + const [emptyBodyY, setEmptyBodyY] = useState(0); + const emptyStateSpacerStyle = useMemo( + () => ({ height: tabBarHeight + Math.max(0, emptyBodyY - top) }), + [emptyBodyY, tabBarHeight, top] + ); + + return ( + { + setEmptyBodyY(event.nativeEvent.layout.y); + }} + > + { + router.push(getNewAgentSessionPath(organizationId) as Href); + }} + > + + {t('home.newCodingTask')} + + } + /> + + + ); +} diff --git a/apps/mobile/src/components/agents/new-session-configure-form.test.ts b/apps/mobile/src/components/agents/new-session-configure-form.test.ts index c0d2fe40ba..b98bc04096 100644 --- a/apps/mobile/src/components/agents/new-session-configure-form.test.ts +++ b/apps/mobile/src/components/agents/new-session-configure-form.test.ts @@ -72,6 +72,7 @@ vi.mock('@/components/agents/new-session-start-button', () => ({ vi.mock('@/components/ui/button', () => ({ Button: 'Button', })); +vi.mock('@/components/ui/icons', () => ({ RefreshCw: 'RefreshCw' })); vi.mock('@/components/ui/segmented-control', () => ({ SegmentedControl: 'SegmentedControl', @@ -171,6 +172,8 @@ function defaultProps() { runOnInstance: null as InstancePickerInstance | null, instanceList: [] as InstancePickerInstance[], isLoadingInstances: false, + isFetchingInstances: false, + onRefreshInstances: vi.fn(), onChangeRunOnInstance: vi.fn(), showInstanceDisconnectedNote: false, folderPath: '', @@ -203,6 +206,44 @@ function defaultProps() { } describe('NewSessionConfigureForm', () => { + it.each([false, true])( + 'refreshes targets while fetching=%s without changing the selection', + async isFetchingInstances => { + const { NewSessionConfigureForm: renderForm } = await import('./new-session-configure-form'); + const props = { + ...defaultProps(), + showRunOnSelector: true, + runOnInstance: INSTANCE, + isFetchingInstances, + }; + const element = renderForm(props); + const button = findElementByType(element, 'Button'); + expect(button).toMatchObject({ + accessibilityLabel: 'Refresh', + size: 'icon', + disabled: isFetchingInstances, + loading: isFetchingInstances, + onPress: props.onRefreshInstances, + }); + const selector = findElementByType(element, 'InstanceSelector'); + expect(selector?.value).toBe(INSTANCE); + if (!button) { + throw new Error('Missing target refresh button'); + } + if (!isFetchingInstances) { + (button.onPress as () => void)(); + expect(props.onRefreshInstances).toHaveBeenCalledOnce(); + expect(props.onChangeRunOnInstance).not.toHaveBeenCalled(); + } + } + ); + + it('disables target refresh during session creation', async () => { + const { NewSessionConfigureForm: renderForm } = await import('./new-session-configure-form'); + const element = renderForm({ ...defaultProps(), showRunOnSelector: true, isCreating: true }); + expect(findElementByType(element, 'Button')?.disabled).toBe(true); + }); + // ── Case 1: Cloud, selector shown ── it('renders prompt, repo, and "Run on" label when cloud target with selector shown', async () => { const { NewSessionConfigureForm } = await import('./new-session-configure-form'); diff --git a/apps/mobile/src/components/agents/new-session-configure-form.tsx b/apps/mobile/src/components/agents/new-session-configure-form.tsx index 83e188cea2..6ebae1b378 100644 --- a/apps/mobile/src/components/agents/new-session-configure-form.tsx +++ b/apps/mobile/src/components/agents/new-session-configure-form.tsx @@ -16,7 +16,10 @@ import { NewSessionStartButton } from '@/components/agents/new-session-start-but import { type AgentMode } from '@/components/agents/mode-selector'; import { type EffectiveAgentProfile } from '@/components/agents/use-effective-agent-profile'; import { type ModeOption } from '@/components/agents/mode-normalize'; +import { Button } from '@/components/ui/button'; +import { RefreshCw } from '@/components/ui/icons'; import { SegmentedControl } from '@/components/ui/segmented-control'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { Text } from '@/components/ui/text'; import { type AgentAttachment, @@ -63,6 +66,8 @@ type NewSessionConfigureFormProps = { runOnInstance: InstancePickerInstance | null; instanceList: InstancePickerInstance[]; isLoadingInstances: boolean; + isFetchingInstances: boolean; + onRefreshInstances: () => void; onChangeRunOnInstance: (next: InstancePickerInstance | null) => void; showInstanceDisconnectedNote: boolean; // Launch folder (remote CLI only). `""` means the launch directory. @@ -132,6 +137,8 @@ export function NewSessionConfigureForm({ runOnInstance, instanceList, isLoadingInstances, + isFetchingInstances, + onRefreshInstances, onChangeRunOnInstance, showInstanceDisconnectedNote, folderPath, @@ -157,6 +164,7 @@ export function NewSessionConfigureForm({ onStartSession, }: Readonly) { const { t } = useTranslation(); + const colors = useThemeColors(); const isRemote = runOnInstance !== null; const isStarting = isRemote ? isSpawningRemote : isCreating; const runOnNote = @@ -170,13 +178,27 @@ export function NewSessionConfigureForm({ {t('agentChat.instancePicker.runOn')} - + + + + + + ); } else if (targetLabel) { diff --git a/apps/mobile/src/components/agents/new-session-screen-body.tsx b/apps/mobile/src/components/agents/new-session-screen-body.tsx index bd9b8c21d4..2b04b90de6 100644 --- a/apps/mobile/src/components/agents/new-session-screen-body.tsx +++ b/apps/mobile/src/components/agents/new-session-screen-body.tsx @@ -247,6 +247,7 @@ export function NewSessionScreenBody() { const { data: instancesData, isLoading: isLoadingInstances, + isFetching: isFetchingInstances, refetch: refetchInstances, } = useQuery({ ...trpc.activeSessions.listInstances.queryOptions(undefined, { @@ -660,6 +661,8 @@ export function NewSessionScreenBody() { runOnInstance={runOnInstance} instanceList={instanceList} isLoadingInstances={isLoadingInstances} + isFetchingInstances={isFetchingInstances} + onRefreshInstances={() => void refetchInstances()} onChangeRunOnInstance={handleRunOnChange} showInstanceDisconnectedNote={remoteSpawn.showInstanceDisconnectedNote} folderPath={folderPath} diff --git a/apps/mobile/src/components/agents/platform-filter-modal.mounted.test.tsx b/apps/mobile/src/components/agents/platform-filter-modal.mounted.test.tsx index b92c78068b..ea9d21da21 100644 --- a/apps/mobile/src/components/agents/platform-filter-modal.mounted.test.tsx +++ b/apps/mobile/src/components/agents/platform-filter-modal.mounted.test.tsx @@ -159,7 +159,7 @@ describe('SessionFilterChips', () => { expect.arrayContaining(['grow-0', 'shrink-0']) ); expect(stripProps.contentContainerClassName?.split(' ')).toEqual( - expect.arrayContaining(['items-center', 'gap-2', 'px-[22px]', 'py-2']) + expect.arrayContaining(['items-center', 'gap-2', 'px-[22px]']) ); expect(stripProps.contentContainerStyle).toBeUndefined(); const pills = strip.findAllByType(Pressable); @@ -169,17 +169,21 @@ describe('SessionFilterChips', () => { expect(pillProps.accessibilityRole).toBe('button'); expect(pillProps.className?.split(' ')).toEqual( expect.arrayContaining([ - 'min-h-[48px]', - 'min-w-[48px]', + 'min-h-[44px]', + 'min-w-[44px]', 'self-center', 'shrink-0', - 'items-center', - 'rounded-full', + 'justify-center', 'active:opacity-70', ]) ); const label = pill.findByType(Text); const labelProps = label.props as ComponentProps; + expect((label.parent?.props.className as string | undefined)?.split(' ')).toEqual( + expect.arrayContaining(['rounded-full', 'px-2', 'py-1']) + ); + expect(pillProps.className).not.toContain('bg-accent-soft'); + expect(labelProps.ellipsizeMode).toBe('tail'); expect(labelProps.numberOfLines).toBe(1); expect(labelProps.className?.split(' ')).toContain('max-w-[220px]'); expect(labelProps.allowFontScaling).not.toBe(false); diff --git a/apps/mobile/src/components/agents/platform-filter-modal.tsx b/apps/mobile/src/components/agents/platform-filter-modal.tsx index e462ece8ef..44e97c9db0 100644 --- a/apps/mobile/src/components/agents/platform-filter-modal.tsx +++ b/apps/mobile/src/components/agents/platform-filter-modal.tsx @@ -117,34 +117,37 @@ export function SessionFilterChips({ horizontal className="grow-0 shrink-0" showsHorizontalScrollIndicator={false} - contentContainerClassName="items-center gap-2 px-[22px] py-2" + contentContainerClassName="items-center gap-2 px-[22px]" > {projectFilter.map(gitUrl => { const label = projectFilterLabel(gitUrl, projectOptions); return ( { onRemoveProject(gitUrl); }} accessibilityRole="button" accessibilityLabel={t('agentChat.sessionFilter.removeProjectFilter', { label })} > - - {label} - - + + + {label} + + + ); })} {platformFilter.map(platform => ( { onRemovePlatform(platform); }} @@ -153,13 +156,16 @@ export function SessionFilterChips({ label: platformFilterLabel(platform), })} > - - {platformFilterLabel(platform)} - - + + + {platformFilterLabel(platform)} + + + ))} @@ -214,7 +220,9 @@ export function SessionFilterModal({ e.stopPropagation(); }} > - {t('agentChat.sessionFilter.title')} + + {t('agentChat.sessionFilter.title')} + diff --git a/apps/mobile/src/components/agents/session-detail-content.test.ts b/apps/mobile/src/components/agents/session-detail-content.test.ts index 07bd0af474..f2478b122d 100644 --- a/apps/mobile/src/components/agents/session-detail-content.test.ts +++ b/apps/mobile/src/components/agents/session-detail-content.test.ts @@ -33,7 +33,6 @@ import { ContextControl } from '@/components/context-control'; import { EmptyState } from '@/components/empty-state'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; -import { AccessibleStatus } from '@/components/ui/accessible-status'; import { i18n } from '@/i18n'; import { renderWithProviders } from '@/test/render-with-providers'; @@ -209,8 +208,14 @@ vi.mock('@/components/agents/use-session-config-sync', () => ({ })); const openRenameModal = vi.hoisted(() => vi.fn()); vi.mock('@/components/agents/use-session-detail-rename', () => ({ - useSessionDetailRename: ({ serverTitle }: { serverTitle?: string }) => ({ - title: serverTitle, + useSessionDetailRename: ({ + serverTitle, + fallbackTitle, + }: { + serverTitle?: string; + fallbackTitle: string; + }) => ({ + title: serverTitle ?? fallbackTitle, isTitleInteractive: serverTitle !== undefined, openModal: openRenameModal, }), @@ -520,18 +525,18 @@ describe('SessionDetailContent display scope', () => { { organizationId: 'org-a', isResolved: true, label: 'Session organization' }, { organizationId: 'missing-org', isResolved: true, label: i18n.t('profile.organization') }, { organizationId: null, isResolved: false, label: i18n.t('profile.selectAccount') }, - ])('renders a read-only $label and preserves header actions', async state => { + ])('omits the $label context label and preserves header actions', async state => { const { renderer } = await mountDetails([], undefined, { organizationId: state.organizationId, isResolved: state.isResolved, }); const header = renderer.root.findByType(ScreenHeader); - const context = header.findByType(ContextControl); - const label = context.findByProps({ accessibilityRole: 'text' }); - expect(label.props).toMatchObject({ - accessibilityLabel: state.label, - accessibilityState: { busy: !state.isResolved }, + expect(header.findByProps({ accessibilityRole: 'header' }).props).toMatchObject({ + numberOfLines: 1, + ellipsizeMode: 'tail', }); + expect(header.props.context).toBeUndefined(); + expect(header.findAllByType(ContextControl)).toHaveLength(0); expect( header.findAll(node => node.props.accessibilityHint === i18n.t('profile.selectAccount')) ).toHaveLength(0); @@ -543,11 +548,6 @@ describe('SessionDetailContent display scope', () => { }).props as { onPress: () => void }; act(onPress); expect(openRenameModal).toHaveBeenCalledOnce(); - if (state.organizationId === 'missing-org') { - expect(context.findByType(AccessibleStatus).props.message).toBe( - i18n.t('organization.boundary.organizationUnavailable') - ); - } pressHeaderBack(renderer); expect(navigationRoutes).toEqual(['/(app)/(tabs)/(2_agents)']); expect(globalContext.organizationId).toBe('global-org'); @@ -612,6 +612,11 @@ describe.each([true, false])('session detail return with history=%s', hasHistory expect(renderedText(view.renderer.root)).toContain('Copy'); } + const header = view.renderer.root.findByType(ScreenHeader); + expect(header.findByProps({ accessibilityRole: 'header' }).props).toMatchObject({ + numberOfLines: 1, + ellipsizeMode: 'tail', + }); pressHeaderBack(view.renderer); expect(navigationRoutes).toEqual( hasHistory ? ['previous-screen'] : ['/(app)/(tabs)/(2_agents)'] diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index c6029b35b3..25433ffab5 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -109,7 +109,7 @@ import { import { performCopy } from '@/components/agents/use-message-copy'; import { QueryError } from '@/components/query-error'; import { RenameModal } from '@/components/rename-modal'; -import { ContextControl, type ContextDisplayScope } from '@/components/context-control'; +import { type ContextDisplayScope } from '@/components/context-control'; import { ScreenHeader } from '@/components/screen-header'; import { AccessibleStatus } from '@/components/ui/accessible-status'; import { BlurBar } from '@/components/ui/blur-bar'; @@ -121,7 +121,7 @@ import { MESSAGE_SENT_EVENT, SESSION_VIEWED_EVENT, } from '@/lib/analytics/posthog'; -import { moveA11yFocus } from '@/lib/a11y/announce'; +import { announceForA11y, moveA11yFocus } from '@/lib/a11y/announce'; import { useAvailableModels } from '@/lib/hooks/use-available-models'; import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; import { useModelPreferences } from '@/lib/hooks/use-model-preferences'; @@ -173,7 +173,6 @@ const EMPTY_IDS: ReadonlySet = new Set(); export function SessionDetailContent({ sessionId, - displayScope, openedVia = 'app', shareId, autoSend, @@ -993,14 +992,12 @@ export function SessionDetailContent({ if (detailsMessageIdRef.current === messageId) { handleCloseDetails(); } - setCancelQueuedStatus({ - messageId, - tone: 'status', - message: composerHasContent + setCancelQueuedStatus(null); + announceForA11y( + composerHasContent ? t('agentChat.session.cancelQueuedRestoreAvailable') - : t('agentChat.session.cancelQueuedRestored'), - attempt, - }); + : t('agentChat.session.cancelQueuedRestored') + ); void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } finally { inFlight.delete(messageId); @@ -1026,13 +1023,8 @@ export function SessionDetailContent({ next.delete(message.info.id); return next; }); - cancelQueuedAttemptRef.current += 1; - setCancelQueuedStatus({ - messageId: message.info.id, - tone: 'status', - message: t('agentChat.session.cancelQueuedRestored'), - attempt: cancelQueuedAttemptRef.current, - }); + setCancelQueuedStatus(null); + announceForA11y(t('agentChat.session.cancelQueuedRestored')); void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }, [canceledQueuedMessages, t] @@ -1396,7 +1388,7 @@ export function SessionDetailContent({ } + titleNumberOfLines={1} backFallback="/(app)/(tabs)/(2_agents)" headerRight={headerRight} {...(rename.isTitleInteractive diff --git a/apps/mobile/src/components/agents/session-detail-queue.test.ts b/apps/mobile/src/components/agents/session-detail-queue.test.ts index 096bdd4bb1..c734c6c880 100644 --- a/apps/mobile/src/components/agents/session-detail-queue.test.ts +++ b/apps/mobile/src/components/agents/session-detail-queue.test.ts @@ -732,7 +732,7 @@ describe('SessionDetailContent cancel/restore', () => { }); expect(hoisted.chatComposer.draft).toEqual({ text: 'Current prompt', files: [FILE_PART] }); expect(readBubble(renderer, selected.info.id)).toBeUndefined(); - expect(statusMessages(renderer)).toEqual([restored]); + expect(statusMessages(renderer)).toEqual([]); unmountScreen(renderer); }); @@ -778,7 +778,7 @@ describe('SessionDetailContent cancel/restore', () => { }); expect(currentManager.cancelQueuedMessage.mock.calls).toEqual([[message.info.id]]); expect(detailsProps(renderer).visible).toBe(false); - expect(statusMessages(renderer)).toEqual([occupied ? restoreAvailable : restored]); + expect(statusMessages(renderer)).toEqual([]); expect(hoisted.announce.mock.calls).toEqual([[occupied ? restoreAvailable : restored]]); if (occupied) { expect(hoisted.chatComposer.draft).toEqual(original); @@ -800,7 +800,7 @@ describe('SessionDetailContent cancel/restore', () => { act(() => { restore(message); }); - expect(statusMessages(renderer)).toEqual([restored]); + expect(statusMessages(renderer)).toEqual([]); expect(hoisted.announce.mock.calls).toEqual([[restoreAvailable], [restored]]); } expect(hoisted.chatComposer.draft).toEqual({ text: text || original.text, files }); @@ -981,7 +981,7 @@ describe('SessionDetailContent cancel/restore', () => { expect(hoisted.chatComposer.draft).toEqual({ text: 'Queued prompt', files: [FILE_PART] }); expect(readBubble(renderer, message.info.id)).toBeUndefined(); expect(detailsProps(renderer).visible).toBe(false); - expect(statusMessages(renderer)).toEqual([restored]); + expect(statusMessages(renderer)).toEqual([]); expect(hoisted.announce.mock.calls).toEqual([[upgrade], [restored]]); unmountScreen(renderer); } @@ -1044,7 +1044,7 @@ describe('SessionDetailContent cancel/restore', () => { expect(hoisted.chatComposer.draft).toEqual({ text: 'Queued prompt', files: [] }); expect(readBubble(renderer, message.info.id)).toBeUndefined(); expect(detailsProps(renderer).visible).toBe(false); - expect(statusMessages(renderer)).toEqual([restored]); + expect(statusMessages(renderer)).toEqual([]); expect(hoisted.announce.mock.calls).toEqual([[failed], [restored]]); unmountScreen(renderer); } @@ -1161,7 +1161,7 @@ describe('SessionDetailContent cancel/restore', () => { }); expect(hoisted.chatComposer.draft).toEqual({ text: 'Next session prompt', files: [] }); expect(readBubble(renderer, nextMessage.info.id)).toBeUndefined(); - expect(statusMessages(renderer)).toEqual([restored]); + expect(statusMessages(renderer)).toEqual([]); unmountScreen(renderer); }); @@ -1288,7 +1288,7 @@ describe('SessionDetailContent cancel/restore', () => { }); expect(detailsProps(renderer).visible).toBe(false); expect(detailsProps(renderer).cancelQueuedFeedback).toBeNull(); - expect(statusMessages(renderer)).toEqual([outcome === 'success' ? restored : failed]); + expect(statusMessages(renderer)).toEqual(outcome === 'success' ? [] : [failed]); expect(hoisted.announce.mock.calls).toEqual([[outcome === 'success' ? restored : failed]]); expect(hoisted.chatComposer.draft.text).toBe(outcome === 'success' ? 'Queued prompt' : ''); closeDetails(renderer); @@ -1356,7 +1356,9 @@ describe('SessionDetailContent cancel/restore', () => { .findAll(node => node.type === Text && node.props.children === failed) ).toHaveLength(1); expect(detailsProps(renderer).canCancelQueued).toBe(outcome !== 'upgrade'); - expect(statusMessages(renderer)).toEqual([firstFeedback, failed]); + expect(statusMessages(renderer)).toEqual( + outcome === 'success' ? [failed] : [firstFeedback, failed] + ); expect(hoisted.announce.mock.calls).toEqual([[failed], [firstFeedback]]); expect(hoisted.chatComposer.draft).toEqual( outcome === 'success' @@ -1370,7 +1372,7 @@ describe('SessionDetailContent cancel/restore', () => { [second.info.id], ]); closeDetails(renderer); - expect(statusMessages(renderer)).toEqual([firstFeedback]); + expect(statusMessages(renderer)).toEqual(outcome === 'success' ? [] : [firstFeedback]); expect(hoisted.announce.mock.calls).toEqual([[failed], [firstFeedback]]); unmountScreen(renderer); } @@ -1466,7 +1468,7 @@ describe('SessionDetailContent cancel/restore', () => { expect(detailsProps(renderer).message?.info.id).toBe(second.info.id); expect(detailsProps(renderer).canCancelQueued).toBe(true); expect(detailsProps(renderer).cancelQueuedFeedback).toBeNull(); - expect(statusMessages(renderer)).toEqual([dropped ? restored : failed]); + expect(statusMessages(renderer)).toEqual(dropped ? [] : [failed]); expect(hoisted.announce.mock.calls).toEqual([[dropped ? restored : failed]]); expect(hoisted.chatComposer.draft.text).toBe(dropped ? 'Queued prompt' : ''); unmountScreen(renderer); diff --git a/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx b/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx index aaa2ffbd97..3dac7843cf 100644 --- a/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx @@ -3,17 +3,21 @@ import { createElement, Fragment, type ReactNode } from 'react'; import TestRenderer, { act } from 'react-test-renderer'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import '@/i18n'; +import { i18n } from '@/i18n'; import type * as PlatformFilterModule from './platform-filter-modal'; import { AgentSessionListScreen } from './session-list-screen'; import { EmptyState } from '@/components/empty-state'; import { ScreenHeader } from '@/components/screen-header'; +import { Text } from '@/components/ui/text'; import { type ActiveSession, type useLiveAgentSessions } from '@/lib/hooks/use-agent-sessions'; import { type BannerState } from '@/lib/offline-banner-state'; type Org = { organizationId: string; organizationName: string }; const state = vi.hoisted(() => ({ focused: true, + fontScale: 1, + topInset: 0, + tabBarHeight: 60, focusCallbacks: new Set<() => void>(), listeners: new Set<(state: string) => void>(), auth: { token: 'account' as string | undefined, isLoading: false, isSigningOut: false }, @@ -57,7 +61,7 @@ vi.mock('react-native', () => ({ ScrollView: 'ScrollView', View: 'View', ActivityIndicator: 'ActivityIndicator', - useWindowDimensions: () => ({ fontScale: 1 }), + useWindowDimensions: () => ({ fontScale: state.fontScale }), AppState: { addEventListener: (_event: string, listener: (next: string) => void) => { state.listeners.add(listener); @@ -87,7 +91,7 @@ vi.mock('react-native-reanimated', () => ({ LinearTransition: 'LinearTransition', })); vi.mock('react-native-safe-area-context', () => ({ - useSafeAreaInsets: () => ({ top: 0, bottom: 0 }), + useSafeAreaInsets: () => ({ top: state.topInset, bottom: 0 }), })); vi.mock('expo-router', () => ({ useNavigation: () => ({ isFocused: () => state.focused }), @@ -196,7 +200,7 @@ vi.mock('@/lib/a11y/announce', () => ({ state.announcements.push(message); }, })); -vi.mock('@/lib/tab-bar-layout', () => ({ getEffectiveTabBarHeight: () => 60 })); +vi.mock('@/lib/tab-bar-layout', () => ({ getEffectiveTabBarHeight: () => state.tabBarHeight })); vi.mock('@/lib/hooks/use-agent-sessions', () => ({ useLiveAgentSessions: () => ({ ...state.live, refetch: state.refetch }), useAgentSessions: () => { @@ -229,7 +233,7 @@ function listSkeletons() { ); } function contextControl() { - return header().find( + return root().find( node => typeof node.type === 'string' && (node.type as string) === 'Pressable' && @@ -305,6 +309,9 @@ function foreground() { beforeEach(() => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; state.focused = true; + state.fontScale = 1; + state.topInset = 0; + state.tabBarHeight = 60; state.focusCallbacks.clear(); state.destination = ''; state.sessionId = ''; @@ -329,10 +336,11 @@ beforeEach(() => { state.invalidate.mockReset(); readFilterRecord.mockReset().mockResolvedValue(null); }); -afterEach(() => { +afterEach(async () => { act(() => mountedRenderer?.unmount()); mountedRenderer = undefined; state.listeners.clear(); + await i18n.changeLanguage('en'); }); describe('AgentSessionListScreen live presentation', () => { @@ -395,6 +403,7 @@ describe('AgentSessionListScreen live presentation', () => { expect(text().includes('Updating')).toBe(Boolean(test.updating)); expect(text().includes('Loading…')).toBe(Boolean(test.skeleton)); expect(nodes('FlatList')).toHaveLength(test.rows ? 1 : 0); + expect(nodes('ScrollView')).toHaveLength(test.empty ? 1 : 0); expect(text()).toContain('Personal'); expect(headerAction().props.testID).toBe('agents-view-history'); expect(headerAction().props.accessibilityRole).toBe('button'); @@ -411,6 +420,60 @@ describe('AgentSessionListScreen live presentation', () => { expect(state.destination).toBe('/(app)/agent-chat/new'); }); + it('compensates the empty state for measured controls and keeps large text scrollable', async () => { + state.topInset = 44; + await renderScreen(); + const scroll = requireNode('ScrollView'); + const emptyState = root().findByType(EmptyState); + const spacer = scroll.findByProps({ pointerEvents: 'none' }); + const onLayout = scroll.props.onLayout as (event: { + nativeEvent: { layout: { y: number } }; + }) => void; + + expect(scroll.parent?.parent).toBe(header().parent); + expect(header().parent?.children[0]).toBe(header()); + expect(header().props.className).toBe('px-[22px] pb-0'); + expect(scroll.findAll(node => node === contextControl())).toHaveLength(0); + expect(scroll.props.className).toBe('flex-1'); + expect(scroll.props.contentContainerClassName).toBe('grow justify-center py-4'); + expect(scroll.props.contentContainerStyle).toBeUndefined(); + expect(scroll.props.scrollEnabled).not.toBe(false); + expect(emptyState.props.placement).toBe('top'); + expect(emptyState.props.className).toBe('shrink-0 pt-0'); + expect(spacer.props.className).toBe('shrink-0'); + expect(spacer.props.style).toEqual({ height: 60 }); + + for (const { y, height } of [ + { y: 180, height: 196 }, + { y: 260, height: 276 }, + { y: 20, height: 60 }, + ]) { + act(() => { + onLayout({ nativeEvent: { layout: { y } } }); + }); + expect(spacer.props.style).toEqual({ height }); + } + + state.fontScale = 2; + state.tabBarHeight = 84; + await renderScreen(); + act(() => { + onLayout({ nativeEvent: { layout: { y: 260 } } }); + }); + expect(spacer.props.style).toEqual({ height: 300 }); + state.topInset = 64; + await renderScreen(); + expect(spacer.props.style).toEqual({ height: 280 }); + const createAction = action('New coding task'); + const label = createAction.findByType(Text); + expect(createAction.props.className).toContain('max-w-full'); + expect(createAction.props.className).toContain('min-h-[44px]'); + expect(label.props.className).toBe('shrink text-center'); + expect(label.props.numberOfLines).toBeUndefined(); + expect(label.props.allowFontScaling).not.toBe(false); + expect(label.props.adjustsFontSizeToFit).not.toBe(true); + }); + it('keeps cold-loading feedback stable until an accepted result', async () => { state.live.hasAcceptedSuccess = false; state.live.isLoading = true; @@ -665,6 +728,87 @@ describe('AgentSessionListScreen live presentation', () => { }); describe('AgentSessionListScreen context control', () => { + it.each([ + { fontScale: 1, filterable: false }, + { fontScale: 1, filterable: true }, + { fontScale: 2, filterable: false }, + { fontScale: 2, filterable: true }, + ])('bounds Hungarian history text at scale $fontScale with filters=$filterable', async test => { + await i18n.changeLanguage('hu'); + state.fontScale = test.fontScale; + const organizationName = 'An organization with a long name that must remain truncated'; + state.organization.organizationId = 'org-1'; + state.boundary.orgs = [{ organizationId: 'org-1', organizationName }]; + state.live.activeSessions = [ + { ...row, gitUrl: test.filterable ? 'https://github.com/kilo/cloud.git' : undefined }, + ]; + await renderScreen(); + const history = action('Összes megtekintése'); + const label = history.findByType(Text); + const actions = history.parent; + const actionSlot = actions?.parent; + const title = header().findByProps({ accessibilityRole: 'header' }); + expect(actionSlot?.props.className).toContain('max-w-[50%]'); + expect(actionSlot?.props.className).not.toContain('shrink-0'); + expect(history.props.className).toContain('min-w-0'); + expect(history.props.className).toContain('shrink'); + expect(actions?.props.className).toContain('items-center'); + expect(label.props.className).toContain('text-center'); + expect(label.props.numberOfLines).toBeUndefined(); + expect(label.props.allowFontScaling).not.toBe(false); + expect(label.props.adjustsFontSizeToFit).not.toBe(true); + expect(title.parent?.parent?.parent).toBe(actionSlot?.parent); + expect(header().props.reserveEyebrow).toBe(true); + expect(header().props.eyebrow).toBe(i18n.t('agents.liveCount', { count: 1 })); + const context = action(organizationName); + expect(context.findByType(Text).props.numberOfLines).toBe(1); + expect(nodes('Text').filter(node => node.children.includes(organizationName))).toHaveLength(1); + expect( + nodes('Pressable').filter(node => node.props.testID === 'agents-open-filters') + ).toHaveLength(test.filterable ? 1 : 0); + press('Összes megtekintése'); + expect(state.destination).toBe('/(app)/(tabs)/(2_agents)/history'); + }); + + it('hides cached names and rows until membership resolves', async () => { + state.organization.organizationId = 'org-1'; + state.boundary.orgs = [{ organizationId: 'org-1', organizationName: 'Engineering' }]; + state.boundary.isResolving = true; + state.live.activeSessions = [row]; + await renderScreen(); + expect(text()).not.toContain('Engineering'); + expect(contextControl().props.accessibilityLabel).toBe('Organization'); + expect(nodes('FlatList')).toHaveLength(0); + expect(header().props.eyebrow).toBeUndefined(); + state.boundary.isResolving = false; + await renderScreen(); + expect(nodes('Text').filter(node => node.children.includes('Engineering'))).toHaveLength(1); + expect(contextControl().props.accessibilityLabel).toBe('Engineering'); + expect(nodes('FlatList')).toHaveLength(1); + }); + + it('right-aligns one context picker above search and centers the header controls', async () => { + state.live.activeSessions = [{ ...row, gitUrl: 'https://github.com/kilo/cloud.git' }]; + await renderScreen(); + expect(header().props.context).toBeUndefined(); + expect(header().props.className).toContain('pb-0'); + expect(contextControl().parent?.parent?.parent?.props.className).toContain('items-end'); + expect(nodes('Text').filter(node => node.children.includes('Personal'))).toHaveLength(1); + expect(nodes('SessionListSearchHeader')).toHaveLength(1); + const history = nodes('Pressable').find(node => node.props.testID === 'agents-view-history'); + const filters = nodes('Pressable').find(node => node.props.testID === 'agents-open-filters'); + expect(history?.parent?.props.className).toContain('items-center'); + expect(history?.parent?.props.className).toContain('min-h-11'); + expect(filters?.parent?.parent).toBe(history?.parent); + const updating = nodes('Text').find(node => node.children.includes('Updating')); + expect(updating).toBeUndefined(); + state.live.isFetching = true; + await renderScreen(); + expect( + nodes('Text').find(node => node.children.includes('Updating'))?.props.className + ).toContain('absolute'); + }); + it('keeps the context picker and history action mounted', async () => { await renderScreen(); expect(header().parent?.children[0]).toBe(header()); @@ -694,6 +838,7 @@ describe('AgentSessionListScreen context control', () => { describe('AgentSessionListScreen live counts', () => { it.each([ + { count: 0, label: '0 LIVE' }, { count: 1, label: '1 LIVE' }, { count: 3, label: '3 LIVE' }, { count: 4, label: '4 LIVE' }, @@ -719,6 +864,10 @@ describe('AgentSessionListScreen live counts', () => { await renderScreen(); expect(header().props.eyebrow).toBeUndefined(); + expect(header().props.reserveEyebrow).toBe(true); + const reserved = nodes('Text').find(node => node.props.variant === 'eyebrow'); + expect(reserved?.props.className).toContain('opacity-0'); + expect(reserved?.props.accessibilityElementsHidden).toBe(true); expect(nodes('FlatList')).toHaveLength(orgLoaded ? 1 : 0); }); }); diff --git a/apps/mobile/src/components/agents/session-list-screen.tsx b/apps/mobile/src/components/agents/session-list-screen.tsx index a556f70673..3402cd7977 100644 --- a/apps/mobile/src/components/agents/session-list-screen.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.tsx @@ -19,6 +19,7 @@ import { LiveSessionFeedback, useLiveSessionContext, } from '@/components/home/agent-sessions-section'; +import { LiveSessionListEmptyState } from '@/components/agents/live-session-list-empty-state'; import { SessionFilterChips, SessionFilterModal } from '@/components/agents/platform-filter-modal'; import { SessionFilterButton } from '@/components/agents/session-filter-button'; import { SessionListSearchHeader } from '@/components/agents/session-list-search-header'; @@ -111,7 +112,7 @@ export function AgentSessionListScreen() { const seeAllLabel = t('home.seeAll'); const headerRight = ( - + { router.push('/(app)/(tabs)/(2_agents)/history' as Href); @@ -121,9 +122,9 @@ export function AgentSessionListScreen() { accessibilityRole="button" accessibilityLabel={seeAllLabel} testID="agents-view-history" - className="active:opacity-70" + className="min-w-0 shrink justify-center active:opacity-70" > - + {seeAllLabel} @@ -223,23 +224,7 @@ export function AgentSessionListScreen() { ); } else if (content === 'empty') { body = ( - { - router.push(getNewAgentSessionPath(organizationId) as Href); - }} - > - - {t('home.newCodingTask')} - - } - /> + ); } else if (hasLiveRows) { body = ( @@ -261,21 +246,23 @@ export function AgentSessionListScreen() { + + - } - /> - + { - const suggestion = { - requestId: 'req-1', - callId: 'call-1', - text: 'Suggestion text', - actions: [{ label: 'Apply', description: 'Apply this change' }], - }; - return { - resolveSuggestionPresentation: vi.fn(), - manager: { - atoms: { activeSuggestion: {} }, - }, - activeSuggestion: suggestion, - }; -}); +const { state, manager, openPartDetail } = vi.hoisted(() => ({ + state: { activeSuggestion: null as StandaloneSuggestion | null }, + manager: { + atoms: { activeSuggestion: {} }, + acceptSuggestion: vi.fn<() => Promise>(), + dismissSuggestion: vi.fn<() => Promise>(), + }, + openPartDetail: vi.fn(), +})); -vi.mock('react-i18next', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - useTranslation: () => ({ t: (key: string) => key }), - }; -}); -vi.mock('./suggestion-card-state', () => ({ resolveSuggestionPresentation })); +vi.mock('react-native', () => ({ View: 'View' })); +vi.mock('react-i18next', async importOriginal => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key }), +})); vi.mock('./fixed-part-row', () => ({ FixedPartRow: 'FixedPartRow' })); +vi.mock('./mono-scroll-block', () => ({ MonoScrollBlock: 'MonoScrollBlock' })); +vi.mock('./suggestion-card', () => ({ SuggestionCard: 'SuggestionCard' })); +vi.mock('./tool-cards/generic-tool-card', () => ({ GenericToolCardBody: 'GenericToolCardBody' })); +vi.mock('./open-part-detail-context', () => ({ useOpenPartDetail: () => openPartDetail })); +vi.mock('@/components/ui/selectable-text', () => ({ SelectableText: 'SelectableText' })); vi.mock('@/components/ui/icons', () => ({ Sparkles: 'Sparkles' })); -vi.mock('jotai', () => ({ useAtomValue: () => activeSuggestion })); -vi.mock('@/components/agents/session-provider', () => ({ - useSessionManager: () => manager, -})); +vi.mock('jotai', () => ({ useAtomValue: () => state.activeSuggestion })); +vi.mock('@/components/agents/session-provider', () => ({ useSessionManager: () => manager })); -function makeSuggestState(status: ToolPart['state']['status']): ToolPart['state'] { - if (status === 'pending') { - return { status: 'pending', input: {}, raw: '' }; - } - if (status === 'running') { - return { status: 'running', input: {}, time: { start: 0 } }; - } - if (status === 'error') { - return { status: 'error', input: {}, error: 'dismissed', time: { start: 0, end: 1 } }; - } - return { - status: 'completed', - input: {}, - output: '', - title: '', - metadata: {}, - time: { start: 0, end: 1 }, - }; -} +const actions = [ + { label: 'Review', description: 'Review the uncommitted changes', prompt: '/review' }, + { label: 'Test', description: 'Run the focused tests', prompt: 'Run the session tests.' }, +]; +const input = { suggest: 'Choose the next step.\nReview the changes before continuing.', actions }; function makeSuggestPart(status: ToolPart['state']['status']): ToolPart { - return { + const base = { id: 'suggest-1', sessionID: 'session-1', messageID: 'message-1', - type: 'tool', + type: 'tool' as const, callID: 'call-1', tool: 'suggest', - state: makeSuggestState(status), + }; + if (status === 'pending') { + return { ...base, state: { status, input, raw: '' } }; + } + if (status === 'running') { + return { ...base, state: { status, input, time: { start: 0 } } }; + } + if (status === 'error') { + return { + ...base, + state: { status, input, error: 'Request failed', time: { start: 0, end: 1 } }, + }; + } + return { + ...base, + state: { + status, + input, + output: + 'User accepted the suggestion "Review". Carry out the following request now:\n\nReview the changes.', + title: 'User accepted: Review', + metadata: { accepted: actions[0], dismissed: false, truncated: false }, + time: { start: 0, end: 1 }, + }, }; } function findAll( node: unknown, - predicate: (el: React.ReactElement) => boolean -): React.ReactElement[] { - const matches: React.ReactElement[] = []; - function walk(value: unknown): void { - if (value == null || typeof value === 'string' || typeof value === 'number') { - return; - } - if (Array.isArray(value)) { - for (const child of value) { - walk(child); - } - return; - } - if (React.isValidElement(value)) { - if (predicate(value)) { - matches.push(value); - } - const props = value.props as Record; - if (typeof value.type === 'function') { - walk((value.type as React.FunctionComponent)(props)); - } - walk(props.children); - } + type: React.ElementType +): React.ReactElement>[] { + if (Array.isArray(node)) { + return node.flatMap(child => findAll(child, type)); + } + if (!React.isValidElement<{ children?: React.ReactNode }>(node)) { + return []; } - walk(node); - return matches; + const matches = node.type === type ? [node] : []; + return [...matches, ...findAll(node.props.children, type)]; } -function findByType(root: React.ReactElement, type: string): React.ReactElement[] { - return findAll(root, el => el.type === type); +function rowProps(part: ToolPart) { + const row = renderSuggestToolCard({ part }); + expect(row.type).toBe(FixedPartRow); + return row.props as React.ComponentProps; } -describe('SuggestToolCard — interactive suggestion moves to the composer', () => { - beforeEach(() => { - resolveSuggestionPresentation.mockReset(); +beforeEach(() => { + state.activeSuggestion = null; + vi.clearAllMocks(); + manager.acceptSuggestion.mockResolvedValue(); + manager.dismissSuggestion.mockResolvedValue(); +}); + +describe('SuggestToolCard', () => { + it.each(['pending', 'running', 'completed', 'error'] as const)( + 'opens details for %s without taking an action', + status => { + const part = makeSuggestPart(status); + const row = rowProps(part); + expect(row.label).toBe(status === 'error' ? 'Suggestion dismissed' : input.suggest); + row.onPress?.(); + expect(openPartDetail).toHaveBeenCalledWith(part.id); + expect(manager.acceptSuggestion).not.toHaveBeenCalled(); + expect(manager.dismissSuggestion).not.toHaveBeenCalled(); + } + ); + + it('opens the active suggestion before tool input arrives', () => { + state.activeSuggestion = { requestId: 'req-1', callId: 'call-1', text: input.suggest, actions }; + const part = makeSuggestPart('running'); + part.state.input = {}; + const row = rowProps(part); + expect(row.label).toBe(input.suggest); + row.onPress?.(); + expect(openPartDetail).toHaveBeenCalledWith(part.id); }); - it('renders no transcript row for the active suggestion', () => { - resolveSuggestionPresentation.mockReturnValue('interactive'); - // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call - const root = SuggestToolCard({ - part: makeSuggestPart('running'), - }); - expect(root).toBeNull(); + it('does not open an empty pending suggestion', () => { + const part = makeSuggestPart('pending'); + part.state.input = {}; + expect(rowProps(part).onPress).toBeUndefined(); }); }); -describe('SuggestToolCard — compact fixed row', () => { - beforeEach(() => { - resolveSuggestionPresentation.mockReset(); +describe('SuggestToolCardBody', () => { + it('shows complete historical suggestion text, actions, prompts, and output', () => { + const part = makeSuggestPart('completed'); + const body = renderSuggestToolCardBody({ part }); + expect(findAll(body, SelectableText).map(node => node.props.children)).toEqual([ + input.suggest, + actions[0]?.label, + actions[0]?.description, + actions[1]?.label, + actions[1]?.description, + ]); + expect(findAll(body, MonoScrollBlock).map(node => node.props.content)).toEqual([ + '/review', + 'Run the session tests.', + part.state.status === 'completed' ? part.state.output : '', + ]); + expect(findAll(body, SuggestionCard)).toHaveLength(0); }); - it('renders a disabled fixed row for a pending suggestion', () => { - resolveSuggestionPresentation.mockReturnValue('compact'); - // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call - const root = SuggestToolCard({ - part: makeSuggestPart('pending'), - }) as unknown as React.ReactElement; - - const rows = findByType(root, 'FixedPartRow'); - expect(rows).toHaveLength(1); - const row = rows[0]; - if (!row) { - throw new Error('row not found'); - } - const rowProps = row.props as { - icon: string; - label: string; - status: string; - accessibilityLabel: string; - onPress?: unknown; - }; - expect(rowProps).toMatchObject({ - icon: 'Sparkles', - label: 'Suggestion', - status: 'pending', - accessibilityLabel: 'agentChat.toolCard.accessibility', - }); - expect(rowProps.onPress).toBeUndefined(); + it('shows active request details without duplicating the composer action controls', () => { + state.activeSuggestion = { requestId: 'req-1', callId: 'call-1', text: input.suggest, actions }; + const part = makeSuggestPart('running'); + part.state.input = {}; + const body = renderSuggestToolCardBody({ part }); + expect(findAll(body, SelectableText).map(node => node.props.children)).toEqual([ + input.suggest, + actions[0]?.label, + actions[0]?.description, + actions[1]?.label, + actions[1]?.description, + ]); + expect(findAll(body, SuggestionCard)).toHaveLength(0); + expect(manager.acceptSuggestion).not.toHaveBeenCalled(); + expect(manager.dismissSuggestion).not.toHaveBeenCalled(); }); - it('uses the plain label for a completed suggestion', () => { - resolveSuggestionPresentation.mockReturnValue('compact'); - // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call - const root = SuggestToolCard({ - part: makeSuggestPart('completed'), - }) as unknown as React.ReactElement; - const row = findByType(root, 'FixedPartRow')[0]; - if (!row) { - throw new Error('row not found'); + it.each(['completed', 'error', 'running'] as const)( + 'does not bind unrelated active actions to a %s tool', + status => { + state.activeSuggestion = { + requestId: 'req-other', + callId: 'other-call', + text: 'Other request', + actions, + }; + expect( + findAll(renderSuggestToolCardBody({ part: makeSuggestPart(status) }), SuggestionCard) + ).toHaveLength(0); } - const rowProps = row.props as { label: string; accessibilityLabel: string }; - expect(rowProps.label).toBe('Suggestion'); - expect(rowProps.accessibilityLabel).toBe('agentChat.toolCard.accessibility'); + ); + + it('shows the actual error text', () => { + const body = renderSuggestToolCardBody({ part: makeSuggestPart('error') }); + expect(findAll(body, SelectableText).map(node => node.props.children)).toContain( + 'Request failed' + ); }); - it('uses the dismissed label for an error suggestion', () => { - resolveSuggestionPresentation.mockReturnValue('compact'); - // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call - const root = SuggestToolCard({ - part: makeSuggestPart('error'), - }) as unknown as React.ReactElement; - const row = findByType(root, 'FixedPartRow')[0]; - if (!row) { - throw new Error('row not found'); + it('keeps a completed dismissal inspectable', () => { + const part = makeSuggestPart('completed'); + if (part.state.status !== 'completed') { + throw new Error('Expected a completed part'); } - const rowProps = row.props as { label: string; status: string; accessibilityLabel: string }; - expect(rowProps.label).toBe('Suggestion dismissed'); - expect(rowProps.status).toBe('error'); - expect(rowProps.accessibilityLabel).toBe('agentChat.toolCard.accessibility'); + part.state.metadata = { dismissed: true, truncated: false }; + part.state.output = 'User dismissed the suggestion.'; + expect(rowProps(part).label).toBe('Suggestion dismissed'); + const body = renderSuggestToolCardBody({ part }); + expect(findAll(body, MonoScrollBlock).map(node => node.props.content)).toContain( + part.state.output + ); + }); + + it.each([ + {}, + { suggest: 42, actions }, + { suggest: 'Legacy input', actions: [{ label: 'Missing prompt' }] }, + ])('falls back to raw details for malformed input %j', malformed => { + const part = makeSuggestPart('completed'); + part.state.input = malformed; + expect(renderSuggestToolCardBody({ part }).type).toBe(GenericToolCardBody); }); }); diff --git a/apps/mobile/src/components/agents/suggest-tool-card.tsx b/apps/mobile/src/components/agents/suggest-tool-card.tsx index 288e66f8a5..da021f3146 100644 --- a/apps/mobile/src/components/agents/suggest-tool-card.tsx +++ b/apps/mobile/src/components/agents/suggest-tool-card.tsx @@ -1,30 +1,35 @@ import { useAtomValue } from 'jotai'; +import { View } from 'react-native'; import { Sparkles } from '@/components/ui/icons'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { useTranslation } from 'react-i18next'; import { useSessionManager } from '@/components/agents/session-provider'; +import { SelectableText } from '@/components/ui/selectable-text'; import { FixedPartRow } from './fixed-part-row'; -import { resolveSuggestionPresentation } from './suggestion-card-state'; -import { getToolDisplay } from './tool-card-display'; +import { MonoScrollBlock } from './mono-scroll-block'; +import { useOpenPartDetail } from './open-part-detail-context'; +import { resolveSuggestionPresentation, suggestionToolInputSchema } from './suggestion-card-state'; +import { getToolDisplay, toolPartHasDetails } from './tool-card-display'; +import { GenericToolCardBody } from './tool-cards/generic-tool-card'; -export function SuggestToolCard({ part }: Readonly<{ part: ToolPart }>) { +function useActiveToolSuggestion(part: ToolPart) { const manager = useSessionManager(); - const { t } = useTranslation(); const activeSuggestion = useAtomValue(manager.atoms.activeSuggestion); - const presentation = resolveSuggestionPresentation( - part.state.status, - part.callID, - activeSuggestion - ); - - if (presentation === 'interactive') { - return null; - } + const matches = + resolveSuggestionPresentation(part.state.status, part.callID, activeSuggestion) === + 'interactive'; + return matches ? activeSuggestion : null; +} +export function SuggestToolCard({ part }: Readonly<{ part: ToolPart }>) { + const suggestion = useActiveToolSuggestion(part); + const { t } = useTranslation(); + const openPartDetail = useOpenPartDetail(); const display = getToolDisplay(part); - const label = display.subtitle ?? display.title; + const label = suggestion?.text ?? display.subtitle ?? display.title; + const hasDetails = suggestion !== null || toolPartHasDetails(part); return ( ) { label, status: part.state.status, })} + onPress={ + hasDetails && openPartDetail + ? () => { + openPartDetail(part.id); + } + : undefined + } /> ); } + +export function SuggestToolCardBody({ part }: Readonly<{ part: ToolPart }>) { + const suggestion = useActiveToolSuggestion(part); + const input = suggestionToolInputSchema.safeParse(part.state.input); + const details = + suggestion ?? + (input.success ? { text: input.data.suggest, actions: input.data.actions } : null); + if (!details) { + return ; + } + + return ( + + {details.text} + {details.actions.map((action, index) => ( + + + {action.label} + + {action.description ? ( + + {action.description} + + ) : null} + + + ))} + {part.state.status === 'completed' && part.state.output ? ( + + ) : null} + {part.state.status === 'error' ? ( + {part.state.error} + ) : null} + + ); +} diff --git a/apps/mobile/src/components/agents/suggestion-card-state.ts b/apps/mobile/src/components/agents/suggestion-card-state.ts index d6ed0bf9c1..a206b209ac 100644 --- a/apps/mobile/src/components/agents/suggestion-card-state.ts +++ b/apps/mobile/src/components/agents/suggestion-card-state.ts @@ -1,5 +1,18 @@ +import { suggestionActionSchema } from '@kilocode/cloud-agent-sdk/schemas'; +import { z } from 'zod'; + import { i18n } from '@/i18n'; +export const suggestionToolInputSchema = z.object({ + suggest: z.string(), + actions: z.array(suggestionActionSchema), +}); + +export const suggestionToolMetadataSchema = z.object({ + accepted: suggestionActionSchema.optional(), + dismissed: z.boolean().optional(), +}); + type ToolStatus = 'pending' | 'running' | 'completed' | 'error'; type ActiveSuggestionIdentity = { requestId: string; callId?: string } | null; diff --git a/apps/mobile/src/components/agents/tool-card-display.test.ts b/apps/mobile/src/components/agents/tool-card-display.test.ts index f4d6d1e9e2..9cd795fcef 100644 --- a/apps/mobile/src/components/agents/tool-card-display.test.ts +++ b/apps/mobile/src/components/agents/tool-card-display.test.ts @@ -16,7 +16,10 @@ function makeToolPart(tool: string, state: ToolPart['state']): ToolPart { }; } -function completed(input: Record = {}, output = ''): ToolPart['state'] { +function completed( + input: Record = {}, + output = '' +): Extract { return { status: 'completed', input, @@ -245,6 +248,23 @@ describe('getToolDisplay mapping', () => { }); }); + it('uses the suggest input text and recognizes completed dismissal metadata', () => { + expect( + getDisplay(makeToolPart('suggest', completed({ suggest: 'Review the changes?' }))) + ).toEqual({ + title: 'Suggestion', + subtitle: 'Review the changes?', + }); + expect( + getDisplay( + makeToolPart('suggest', { + ...completed({ suggest: 'Review the changes?' }), + metadata: { dismissed: true, truncated: false }, + }) + ) + ).toEqual({ title: 'Suggestion', subtitle: 'Suggestion dismissed' }); + }); + it('maps an MCP tool to server/tool title', () => { expect( getDisplay( @@ -409,8 +429,13 @@ describe('getToolDisplay badge rules — live CLI shapes', () => { }); describe('toolPartHasDetails', () => { - it('returns false for suggest even with input', () => { - expect(toolPartHasDetails(makeToolPart('suggest', completed({ prompt: 'hi' })))).toBe(false); + it('opens suggestion details when input or output exists', () => { + expect( + toolPartHasDetails(makeToolPart('suggest', completed({ suggest: 'Review the changes?' }))) + ).toBe(true); + expect( + toolPartHasDetails(makeToolPart('suggest', completed({}, 'User dismissed the suggestion.'))) + ).toBe(true); }); it('returns false for a running part with empty input and no output', () => { diff --git a/apps/mobile/src/components/agents/tool-card-display.ts b/apps/mobile/src/components/agents/tool-card-display.ts index 9cc3247750..4fbcb4e0f8 100644 --- a/apps/mobile/src/components/agents/tool-card-display.ts +++ b/apps/mobile/src/components/agents/tool-card-display.ts @@ -12,6 +12,7 @@ import { } from './tool-card-utils'; import { listPatchFilePaths } from './tool-patch-model'; import { buildResultRowsModel } from './tool-list-model'; +import { suggestionToolMetadataSchema } from './suggestion-card-state'; export type ToolDisplay = { title: string; @@ -50,6 +51,7 @@ const toolInputSchema = z.object({ query: optionalString, url: optionalString, prompt: optionalString, + suggest: optionalString, }); /** @@ -194,13 +196,17 @@ export function getToolDisplay(part: ToolPart): ToolDisplay { return { title: i18n.t('agentChat.toolCard.toolTask'), subtitle }; } case 'suggest': { - return { - title: i18n.t('agentChat.suggestion.title'), - subtitle: - status === 'error' - ? i18n.t('agentChat.suggestion.dismissed') - : i18n.t('agentChat.suggestion.title'), - }; + const metadata = + status === 'completed' ? suggestionToolMetadataSchema.safeParse(part.state.metadata) : null; + const dismissed = metadata?.success && metadata.data.dismissed; + const title = i18n.t('agentChat.suggestion.title'); + let subtitle = fields.suggest?.trim() ?? title; + if (status === 'error' || dismissed) { + subtitle = i18n.t('agentChat.suggestion.dismissed'); + } else if (subtitle === '') { + subtitle = title; + } + return { title, subtitle }; } default: { const stateTitle = @@ -210,15 +216,7 @@ export function getToolDisplay(part: ToolPart): ToolDisplay { } } -/** - * Whether a tool part has content that a detail sheet could show. Suggest parts - * are never detailed. Everything else is detailed when input, completed output, - * error content, or any attachment exists. - */ export function toolPartHasDetails(part: ToolPart): boolean { - if (part.tool === 'suggest') { - return false; - } if (Object.keys(part.state.input).length > 0) { return true; } diff --git a/apps/mobile/src/components/agents/tool-part-detail-body.test.ts b/apps/mobile/src/components/agents/tool-part-detail-body.test.ts index 2ba47d2d55..c23b779d23 100644 --- a/apps/mobile/src/components/agents/tool-part-detail-body.test.ts +++ b/apps/mobile/src/components/agents/tool-part-detail-body.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type * as ReactI18next from 'react-i18next'; import { ToolPartDetailBody } from './tool-part-detail-body'; +import { SuggestToolCardBody } from './suggest-tool-card'; import { BashToolCardBody, EditToolCardBody, @@ -21,6 +22,7 @@ import { import { BashToolCardBody as RealBashToolCardBody } from './tool-cards/bash-tool-card'; vi.mock('react-native', () => ({ View: 'View' })); +vi.mock('./suggest-tool-card', () => ({ SuggestToolCardBody: 'SuggestToolCardBody' })); vi.mock('react-i18next', async importOriginal => { const actual = await importOriginal(); return { @@ -200,6 +202,7 @@ const routingTable: [string, ToolBody][] = [ ['todoread', TodoToolCardBody], ['todowrite', TodoToolCardBody], ['task', TaskToolCardBody], + ['suggest', SuggestToolCardBody], ]; describe('ToolPartDetailBody routing', () => { @@ -220,26 +223,6 @@ describe('ToolPartDetailBody routing', () => { expect(findByType(root, GenericToolCardBody)).toHaveLength(1); }); - it('renders no body for suggest parts', () => { - const allBodies = [ - BashToolCardBody, - EditToolCardBody, - GenericToolCardBody, - GlobToolCardBody, - GrepToolCardBody, - ListToolCardBody, - PatchToolCardBody, - ReadToolCardBody, - TaskToolCardBody, - TodoToolCardBody, - WebSearchToolCardBody, - WriteToolCardBody, - ]; - // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call - const root = ToolPartDetailBody({ part: makeToolPart('suggest', completedState) }); - expect(allBodies.flatMap(body => findByType(root, body))).toHaveLength(0); - }); - it('renders attachments above the body when present', () => { getToolImageAttachments.mockReturnValue([makeFilePart('img-1', 'image/png')]); getToolFileAttachments.mockReturnValue([makeFilePart('file-1', 'application/pdf')]); diff --git a/apps/mobile/src/components/agents/tool-part-detail-body.tsx b/apps/mobile/src/components/agents/tool-part-detail-body.tsx index 04b839df8c..169691389c 100644 --- a/apps/mobile/src/components/agents/tool-part-detail-body.tsx +++ b/apps/mobile/src/components/agents/tool-part-detail-body.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { Text } from '@/components/ui/text'; +import { SuggestToolCardBody } from './suggest-tool-card'; import { getToolFileAttachments, getToolImageAttachments } from './tool-card-attachments'; import { ToolCardFileAttachments } from './tool-card-file-attachments'; import { ToolCardImageAttachments } from './tool-card-image-attachments'; @@ -62,7 +63,7 @@ function renderToolBody(part: ToolPart): React.ReactNode { return ; } case 'suggest': { - return null; + return ; } default: { return ; @@ -70,11 +71,6 @@ function renderToolBody(part: ToolPart): React.ReactNode { } } -/** - * Sheet body dispatcher for a tool part. Renders a uniform pending/running - * status line, the attachments above the per-tool body, then the type-specific - * body. Suggest parts have no body; unknown tools use the generic body. - */ export function ToolPartDetailBody({ part }: Readonly<{ part: ToolPart }>) { const { t } = useTranslation(); const status = part.state.status; diff --git a/apps/mobile/src/components/agents/tool-part-renderer.test.ts b/apps/mobile/src/components/agents/tool-part-renderer.test.ts index 1e32a648c5..8464a66375 100644 --- a/apps/mobile/src/components/agents/tool-part-renderer.test.ts +++ b/apps/mobile/src/components/agents/tool-part-renderer.test.ts @@ -335,6 +335,30 @@ describe.each(['top-level', 'nested'] as const)('%s child-card initial history', }); }); +describe('ChildSessionMessage visibility', () => { + it.each([ + [], + [{ type: 'step-start', id: 'step', sessionID: 's1', messageID: 'm1' }], + [{ type: 'text', text: ' ', id: 'text', sessionID: 's1', messageID: 'm1' }], + [makeToolPart('plan_exit', completedState)], + ] satisfies Part[][])('renders no gray wrapper for hidden parts %j', async (...parts) => { + const { renderer, unmount } = await renderWithProviders( + React.createElement(ChildSessionMessage, { + message: makeStoredMessage(parts), + depth: 0, + getChildMessages: () => [], + renderPart: () => null, + onOpenChildSession: vi.fn<(sessionId: string, title: string) => void>(), + }) + ); + try { + expect(renderer.toJSON()).toBeNull(); + } finally { + unmount(); + } + }); +}); + describe('ToolPartRenderer routing', () => { it.each(routingTable)('routes tool %s to its card', (tool, card) => { // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call diff --git a/apps/mobile/src/components/app-unlock-screen.layout.mounted.test.tsx b/apps/mobile/src/components/app-unlock-screen.layout.mounted.test.tsx new file mode 100644 index 0000000000..f817211eca --- /dev/null +++ b/apps/mobile/src/components/app-unlock-screen.layout.mounted.test.tsx @@ -0,0 +1,31 @@ +import { afterEach, beforeEach, expect, it } from 'vitest'; + +import { + mount, + native, + resetUnlockMocks, + retry, + unlockRoot, + unmountUnlock, +} from '@/components/app-unlock-screen.test-helpers'; + +beforeEach(resetUnlockMocks); +afterEach(unmountUnlock); + +it('centers the unlock content and keeps a separate gap before Retry', async () => { + native.authenticateAsync.mockResolvedValueOnce({ success: false, error: 'user_cancel' }); + await mount(); + const heading = unlockRoot().findByProps({ accessibilityRole: 'header' }); + const copy = heading.parent; + const content = copy?.parent; + expect(content?.props.className).toContain('gap-8'); + expect(content?.parent?.props.contentContainerStyle).toMatchObject({ + flexGrow: 1, + justifyContent: 'center', + paddingTop: 48, + paddingBottom: 36, + }); + expect(content?.findAll(node => node === retry())).toHaveLength(1); + expect(copy?.findAll(node => node === retry())).toHaveLength(0); + expect(retry()?.props.accessibilityLabel).toBe('Retry'); +}); diff --git a/apps/mobile/src/components/app-unlock-screen.tsx b/apps/mobile/src/components/app-unlock-screen.tsx index f41f6a2a26..0fa486e0be 100644 --- a/apps/mobile/src/components/app-unlock-screen.tsx +++ b/apps/mobile/src/components/app-unlock-screen.tsx @@ -67,6 +67,8 @@ export function AppUnlockFeedback({ outcome }: Readonly<{ outcome: UnlockOutcome function contentPadding({ top, bottom, left, right }: EdgeInsets) { return { + flexGrow: 1, + justifyContent: 'center' as const, paddingTop: top + 24, paddingBottom: bottom + 24, paddingLeft: left + 24, @@ -94,32 +96,32 @@ function AppUnlockScene({ children }: Readonly<{ children: ReactElement }>) { {hidden ? ( - - - {t('preferences.biometricUnlock')} - - - - {status === 'preference-loading' ? ( - - + + + + + {t('preferences.biometricUnlock')} + + + - ) : ( - - )} + {status === 'preference-loading' ? ( + + + + ) : ( + + )} + ) : null} diff --git a/apps/mobile/src/components/context-control.mounted.test.tsx b/apps/mobile/src/components/context-control.mounted.test.tsx index ba31ab0f1d..8f55976156 100644 --- a/apps/mobile/src/components/context-control.mounted.test.tsx +++ b/apps/mobile/src/components/context-control.mounted.test.tsx @@ -136,6 +136,7 @@ describe('ContextControl', () => { await waitFor(() => !picker(ui).props.disabled); expect(texts(ui)).toContain(label); expect(picker(ui).props.accessibilityLabel).toBe(label); + expect(picker(ui).findByType(Text).props.numberOfLines).toBe(1); expect(picker(ui).props.accessibilityRole).toBe('button'); expect(picker(ui).props.accessibilityState).toEqual({ busy: false, disabled: false }); expect(picker(ui).findAllByType('ActivityIndicator' as ElementType)).toHaveLength(0); diff --git a/apps/mobile/src/components/context-control.tsx b/apps/mobile/src/components/context-control.tsx index 565cecbe0e..e68697bddb 100644 --- a/apps/mobile/src/components/context-control.tsx +++ b/apps/mobile/src/components/context-control.tsx @@ -58,7 +58,10 @@ export function useContextPicker(orgs: OrgListEntry[] | undefined) { } /** An explicit scope is always read-only; explicit null never inherits global scope. */ -export function ContextControl({ scope }: { readonly scope?: ContextDisplayScope }) { +export function ContextControl({ + scope, + showOrganizationName = true, +}: Readonly<{ scope?: ContextDisplayScope; showOrganizationName?: boolean }>) { const context = useOrganization(); const { token } = useAuth(); const trpc = useTRPC(); @@ -79,10 +82,11 @@ export function ContextControl({ scope }: { readonly scope?: ContextDisplayScope const pending = (!isResolved && providerError !== 'restore') || (isResolved && organizationId !== null && orgs === undefined && !nameError); + const organizationName = showOrganizationName ? org?.organizationName : undefined; let label = organizationId === null ? t('profile.personal') - : (org?.organizationName ?? t('profile.organization')); + : (organizationName ?? t('profile.organization')); if (!isResolved) { label = t('profile.selectAccount'); } @@ -107,13 +111,19 @@ export function ContextControl({ scope }: { readonly scope?: ContextDisplayScope const disabled = !isResolved || orgs === undefined; const pickerBusy = pending || (orgs === undefined && token != null && organizations.isPending); const content = pending ? ( - + ) : ( - {label} + + {label} + ); return ( - + {scope ? ( {content} - + {pickerBusy ? ( ) : ( diff --git a/apps/mobile/src/components/home/agent-sessions-section.tsx b/apps/mobile/src/components/home/agent-sessions-section.tsx index bd5b8d8413..28c2b063d4 100644 --- a/apps/mobile/src/components/home/agent-sessions-section.tsx +++ b/apps/mobile/src/components/home/agent-sessions-section.tsx @@ -193,13 +193,19 @@ export function LiveSessionFeedback({ return ( - {context.label && {context.label}} {/* The app-wide OfflineBanner owns the offline announcement. */} {internet === 'offline' ? ( {t('offline.noInternet')} ) : ( - + )} {context.isReady && !isConnected && reconnectExhausted && ( } diff --git a/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.mounted.test.tsx b/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.mounted.test.tsx new file mode 100644 index 0000000000..ff7c1a1d47 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.mounted.test.tsx @@ -0,0 +1,128 @@ +import { QueryObserver } from '@tanstack/react-query'; +import { act, createElement } from 'react'; +import { createTestQueryClient, renderWithProviders, waitFor } from '@/test/render-with-providers'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PrReviewReconnectNotice } from './pr-review-reconnect-notice'; + +const mocks = vi.hoisted(() => ({ + authorization: vi.fn<() => Promise<{ connected: boolean; revoked: boolean }>>(), + review: vi.fn<() => Promise>(), + toastError: vi.fn(), +})); +const authorizationKey = ['githubApps', 'getUserAuthorization']; +const reviewKey = ['githubPrReview', 'getPullRequest']; + +vi.mock('react-native', () => ({ View: 'View' })); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('sonner-native', () => ({ toast: { error: mocks.toastError } })); +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + githubApps: { + getUserAuthorization: { + queryOptions: () => ({ + queryKey: authorizationKey, + queryFn: mocks.authorization, + staleTime: Infinity, + }), + }, + }, + githubPrReview: { pathFilter: () => ({ queryKey: ['githubPrReview'] }) }, + }), +})); + +let view: Awaited> | undefined = undefined; +let client = createTestQueryClient(); +let unsubscribe: (() => void) | undefined = undefined; + +beforeEach(() => { + vi.clearAllMocks(); + client = createTestQueryClient(); + client.setQueryData(authorizationKey, { connected: true, revoked: false }); + mocks.authorization.mockResolvedValue({ connected: true, revoked: false }); + mocks.review.mockResolvedValue('recovered'); +}); + +afterEach(() => { + view?.unmount(); + view = undefined; + unsubscribe?.(); + unsubscribe = undefined; + client.clear(); +}); + +async function mountNotice() { + const observer = new QueryObserver(client, { + queryKey: reviewKey, + queryFn: mocks.review, + staleTime: Infinity, + initialData: 'cached', + }); + unsubscribe = observer.subscribe(vi.fn<() => void>()); + view = await renderWithProviders(createElement(PrReviewReconnectNotice), { queryClient: client }); +} + +function button() { + const result = view?.renderer.root.find(node => (node.type as string) === 'Button'); + if (!result) { + throw new Error('Connection button not found'); + } + return result.props as { onPress: () => void; loading: boolean }; +} + +async function checkConnection() { + await act(async () => { + button().onPress(); + await Promise.resolve(); + }); + await waitFor(() => !button().loading && client.isMutating() === 0); +} + +describe('PrReviewReconnectNotice', () => { + it('checks fresh authorization and refetches review queries when still connected', async () => { + await mountNotice(); + await checkConnection(); + + expect(mocks.authorization).toHaveBeenCalledOnce(); + expect(mocks.review).toHaveBeenCalledOnce(); + expect(client.getQueryData(reviewKey)).toBe('recovered'); + }); + + it('updates the authorization gate without retrying reviews when revoked', async () => { + mocks.authorization.mockResolvedValue({ connected: false, revoked: true }); + await mountNotice(); + await checkConnection(); + + expect(client.getQueryData(authorizationKey)).toEqual({ connected: false, revoked: true }); + expect(mocks.review).not.toHaveBeenCalled(); + }); + + it('shows a failed connection check without retrying review queries', async () => { + mocks.authorization.mockRejectedValue(new Error('Connection check failed')); + await mountNotice(); + await checkConnection(); + + expect(mocks.toastError).toHaveBeenCalledWith('Connection check failed'); + expect(mocks.review).not.toHaveBeenCalled(); + }); + + it('shows progress until the connection check finishes', async () => { + const authorization = Promise.withResolvers<{ connected: boolean; revoked: boolean }>(); + mocks.authorization.mockReturnValue(authorization.promise); + await mountNotice(); + await act(async () => { + button().onPress(); + await Promise.resolve(); + }); + await waitFor(() => button().loading); + + await act(async () => { + authorization.resolve({ connected: true, revoked: false }); + await authorization.promise; + }); + await waitFor(() => !button().loading); + expect(mocks.review).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx b/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx index 1900ab038b..5c2404beb9 100644 --- a/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx @@ -1,31 +1,14 @@ -import { useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { View } from 'react-native'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; -import { useTRPC } from '@/lib/trpc'; +import { useCheckGitHubConnection } from '@/lib/pr-review/use-check-github-connection'; -/** - * Shared reconnect affordance for PR Review surfaces. A - * PRECONDITION_FAILED on a query or mutation means the gate's GitHub - * authorization is no longer valid even though the gate passed. We - * force a refetch of the gate's query so the wrapping - * `PrReviewConnectGate` renders its own connect/reconnect CTA. The - * caller owns section/tab framing; this component is just the - * message + button. - */ export function PrReviewReconnectNotice() { - const queryClient = useQueryClient(); - const trpc = useTRPC(); + const connection = useCheckGitHubConnection(); const { t } = useTranslation(); - const handleReconnect = () => { - void queryClient.invalidateQueries({ - queryKey: trpc.githubApps.getUserAuthorization.queryKey(), - }); - }; - return ( @@ -34,7 +17,10 @@ export function PrReviewReconnectNotice() { {t('prReview.reconnectNotice.message')}