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
1,024 changes: 981 additions & 43 deletions apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx

Large diffs are not rendered by default.

71 changes: 50 additions & 21 deletions apps/mobile/src/app/(app)/agent-chat/[session-id].tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { type KiloSessionId } from '@kilocode/cloud-agent-sdk';
import { type Href, useLocalSearchParams, useRouter } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { hashKey, useQuery } from '@tanstack/react-query';
import { View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSyncExternalStore } from 'react';

import {
getAuthenticatedOwner,
isAuthenticatedOwner,
subscribeAuthenticatedOwner,
} from '@/lib/context-scope';

import { SessionDetailContent } from '@/components/agents/session-detail-content';
import {
Expand All @@ -12,6 +19,7 @@ import {
import { SessionConnectionIndicator } from '@/components/agents/session-connection-indicator';
import { SessionContextMetrics } from '@/components/agents/session-context-metrics';
import { AgentSessionProvider } from '@/components/agents/session-provider';
import { useIdentityConfirmation } from '@/components/agents/user-web-connection-provider';
import { buildTerminalErrorCopyText } from '@/components/agents/session-terminal-error';
import { performCopy } from '@/components/agents/use-message-copy';
import { InvalidRouteState } from '@/components/invalid-route-state';
Expand All @@ -25,6 +33,10 @@ import { shouldRetryNotFoundOnSpawnedRoute } from '@/lib/spawned-not-found-retry
import { useTRPC } from '@/lib/trpc';

export default function SessionDetailScreen() {
const owner = useSyncExternalStore(subscribeAuthenticatedOwner, getAuthenticatedOwner);
const confirmation = useIdentityConfirmation();
const identityPending = !isAuthenticatedOwner(owner);
const identityFailed = identityPending && confirmation.isError;
const {
'session-id': rawSessionId,
organizationId: routeOrganizationId,
Expand All @@ -33,7 +45,6 @@ export default function SessionDetailScreen() {
shareId: shareIdParam,
autoSend: autoSendRaw,
mode: modeParam,
title: titleParam,
} = useLocalSearchParams<{
'session-id': string;
organizationId?: string;
Expand All @@ -53,7 +64,7 @@ export default function SessionDetailScreen() {
autoSend?: string;
/** Agent mode the spawn was started with; seeds the composer before the CLI reports one. */
mode?: string;
/** Title the list row already showed; paints the header on the first frame. */
/** Legacy title hints remain accepted but carry no account ownership, so ignore them. */
title?: string;
}>();
// `session-id` is required: a malformed deep link can hand us `undefined`
Expand All @@ -64,10 +75,6 @@ export default function SessionDetailScreen() {
const shareId = Array.isArray(shareIdParam) ? shareIdParam[0] : shareIdParam;
const autoSendParam = Array.isArray(autoSendRaw) ? autoSendRaw[0] : autoSendRaw;
const spawnedMode = Array.isArray(modeParam) ? modeParam[0] : modeParam;
// The row the user tapped already showed this title, so the header opens
// with it instead of a generic label that swaps a beat later. Deep links
// and push opens carry no title and keep the fallback.
const cachedTitle = Array.isArray(titleParam) ? titleParam[0] : titleParam;
const trpc = useTRPC();
const router = useRouter();
const { t } = useTranslation();
Expand Down Expand Up @@ -103,20 +110,31 @@ export default function SessionDetailScreen() {
retryDelay: 1000,
}
),
enabled: routeOrganizationId === undefined && sessionId !== null,
// Isolate account metadata while preserving the typed tRPC key and prefix invalidation.
queryHash: hashKey([
...trpc.cliSessionsV2.get.queryKey({ session_id: sessionId ?? '' }),
owner.authEpoch,
owner.generation,
owner.userId,
]),
enabled: isAuthenticatedOwner(owner) && routeOrganizationId === undefined && sessionId !== null,
});

if (sessionId === null) {
return <InvalidRouteState backTo={'/(app)' as Href} />;
}

if (routeOrganizationId === undefined && sessionQuery.isPending) {
if (
!identityFailed &&
(identityPending || (routeOrganizationId === undefined && sessionQuery.isPending))
) {
// The composer placeholder holds its own height: nothing may shift when
// the query resolves.
// the query resolves. Route title hints are not bound to an account.
return (
<View className="flex-1 bg-background">
<ScreenHeader
title={cachedTitle ?? t('agentChat.session.title')}
title={t('agentChat.session.title')}
backFallback="/(app)/(tabs)/(2_agents)"
headerRight={
<SessionContextMetrics
info={undefined}
Expand All @@ -133,17 +151,25 @@ export default function SessionDetailScreen() {
);
}

if (routeOrganizationId === undefined && sessionQuery.isError) {
if (identityFailed || (routeOrganizationId === undefined && sessionQuery.isError)) {
// A NOT_FOUND (e.g. the stored session was deleted) or UNAUTHORIZED
// (org-access denial) can't be recovered by retrying — show a permanent
// state with no Retry. Other errors stay transient and retriable. All
// get Back and Copy.
const errorCode = sessionQuery.error.data?.code;
const errorCode = identityFailed ? undefined : sessionQuery.error?.data?.code;
const notFound = errorCode === 'NOT_FOUND';
const unauthorized = errorCode === 'UNAUTHORIZED';
let title = t('agentChat.session.couldNotLoad');
let message = t('agentChat.session.failedToLoadDetails');
let variant: 'not-found' | 'permission' | 'server' = 'server';
let title = t(
identityFailed ? 'bootstrap.couldNotLoadAccount' : 'agentChat.session.couldNotLoad'
);
let message = t(
identityFailed
? 'bootstrap.couldNotLoadAccountDescription'
: 'agentChat.session.failedToLoadDetails'
);
let variant: 'neutral' | 'not-found' | 'permission' | 'server' = identityFailed
? 'neutral'
: 'server';
if (notFound) {
title = t('agentChat.session.notFound');
message = t('agentChat.session.notFoundDescription');
Expand All @@ -153,10 +179,14 @@ export default function SessionDetailScreen() {
message = t('agentChat.session.accessDeniedDescription');
variant = 'permission';
}
const retry = identityFailed ? confirmation.retry : () => void sessionQuery.refetch();
const copyText = buildTerminalErrorCopyText({ sessionId, title, message });
return (
<View className="flex-1 bg-background">
<ScreenHeader title={t('agentChat.session.title')} />
<ScreenHeader
title={t('agentChat.session.title')}
backFallback="/(app)/(tabs)/(2_agents)"
/>
<SessionConnectionIndicator />
<View className="flex-1 items-center justify-center gap-3 px-6">
<QueryError
Expand All @@ -165,8 +195,8 @@ export default function SessionDetailScreen() {
className="px-0 pt-0"
title={title}
message={message}
onRetry={notFound || unauthorized ? undefined : () => void sessionQuery.refetch()}
isRetrying={sessionQuery.isFetching}
onRetry={notFound || unauthorized ? undefined : retry}
isRetrying={identityFailed ? confirmation.isPending : sessionQuery.isFetching}
/>
<View className="flex-row gap-3">
<Button
Expand Down Expand Up @@ -196,12 +226,11 @@ export default function SessionDetailScreen() {

return (
<AgentSessionProvider
key={`${sessionId}:${organizationId ?? 'personal'}`}
key={`${owner.generation}:${owner.userId}:${sessionId}:${organizationId ?? 'personal'}`}
organizationId={organizationId}
>
<SessionDetailContent
sessionId={sessionId as KiloSessionId}
cachedTitle={cachedTitle}
openedVia={via === 'push' ? 'push' : 'app'}
shareId={shareId}
autoSend={autoSendParam === '1'}
Expand Down
91 changes: 62 additions & 29 deletions apps/mobile/src/components/agents/child-session-card-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,29 @@ function makeAssistantMessage(parts: Part[], id = 'msg-1'): StoredMessage {
}

describe('getChildSessionCardState', () => {
it.each([
['pending', 'Waiting for activity'],
['running', 'Waiting for activity'],
['completed', ''],
['error', ''],
] as const)(
'keeps a %s task useful without child transcript enrichment',
(status, latestActivity) => {
const part = makeTaskPart(status, {
subagent_type: 'Researcher',
description: 'Review access rules',
prompt: 'Read the access configuration before proposing changes.',
});
for (const messages of [[], [makeAssistantMessage([])]]) {
expect(getChildSessionCardState(part, messages)).toEqual({
agentName: 'Researcher',
taskName: 'Review access rules',
latestActivity,
});
}
}
);

it('falls back to Subagent / Task / Waiting for activity for a pending task with empty input', () => {
const part = makeTaskPart('pending');
expect(getChildSessionCardState(part, [])).toEqual({
Expand All @@ -129,26 +152,33 @@ describe('getChildSessionCardState', () => {
expect(state.taskName).toBe(`${'a'.repeat(60)}\u2026`);
});

it('reads a completed read tool from child messages and derives its filename context', () => {
const part = makeTaskPart('running', {
subagent_type: 'Researcher',
description: 'Check spec',
});
const readPart = makeToolPart('read', {
status: 'completed',
input: { filePath: '/project/docs/spec.md' },
output: 'content',
title: 'read',
metadata: {},
time: { start: 1, end: 2 },
});
const messages = [makeAssistantMessage([readPart])];
expect(getChildSessionCardState(part, messages)).toEqual({
agentName: 'Researcher',
taskName: 'Check spec',
latestActivity: { tool: 'read', context: 'spec.md' },
});
});
it.each(['running', 'completed', 'error'] as const)(
'uses the %s task status to control loaded read activity and its label',
status => {
const part = makeTaskPart(status, {
subagent_type: 'Researcher',
description: 'Check spec',
});
const readPart = makeToolPart('read', {
status: 'completed',
input: { filePath: '/project/docs/spec.md' },
output: 'content',
title: 'read',
metadata: {},
time: { start: 1, end: 2 },
});
const messages = [makeAssistantMessage([readPart])];
const state = getChildSessionCardState(part, messages);
expect(state).toEqual({
agentName: 'Researcher',
taskName: 'Check spec',
latestActivity: status === 'running' ? { tool: 'read', context: 'spec.md' } : '',
});
expect(getChildSessionActivityLabel(state.latestActivity)).toBe(
status === 'running' ? 'read spec.md' : ''
);
}
);

it('keeps the latest completed or errored tool when it supersedes an older running tool', () => {
const part = makeTaskPart('running', { subagent_type: 'Builder', description: 'Fix bug' });
Expand Down Expand Up @@ -271,15 +301,18 @@ describe('getChildSessionCardState', () => {
});
});

it('shows live text activity when the latest assistant part is a text part', () => {
const part = makeTaskPart('running', { subagent_type: 'Writer', description: 'Draft reply' });
const messages = [makeAssistantMessage([makeTextPart('Here is the answer.')])];
expect(getChildSessionCardState(part, messages)).toEqual({
agentName: 'Writer',
taskName: 'Draft reply',
latestActivity: 'Writing response',
});
});
it.each(['running', 'completed', 'error'] as const)(
'uses the %s task status to control loaded text activity',
status => {
const part = makeTaskPart(status, { subagent_type: 'Writer', description: 'Draft reply' });
const messages = [makeAssistantMessage([makeTextPart('Here is the answer.')])];
expect(getChildSessionCardState(part, messages)).toEqual({
agentName: 'Writer',
taskName: 'Draft reply',
latestActivity: status === 'running' ? 'Writing response' : '',
});
}
);

it('shows live reasoning activity when the latest assistant part is a reasoning part', () => {
const part = makeTaskPart('running', { subagent_type: 'Thinker', description: 'Reason' });
Expand Down
Loading