diff --git a/apps/mobile/src/components/agents/exit-remote-session-from-list.test.ts b/apps/mobile/src/components/agents/exit-remote-session-from-list.test.ts new file mode 100644 index 0000000000..1a0955aa36 --- /dev/null +++ b/apps/mobile/src/components/agents/exit-remote-session-from-list.test.ts @@ -0,0 +1,239 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { announcingToast } from '@/lib/a11y/announcing-toast'; + +import { exitRemoteSessionFromList } from './exit-remote-session-from-list'; + +vi.mock('@/lib/a11y/announcing-toast', () => ({ + announcingToast: { success: vi.fn(), error: vi.fn() }, +})); + +type RetryAction = { label: string; onClick: () => void }; + +function createHarness() { + const confirm = vi.fn(async () => { + await Promise.resolve(); + return true; + }); + const sendExit = vi.fn(async () => { + await Promise.resolve(); + }); + const refreshActiveList = vi.fn(async () => { + await Promise.resolve(); + }); + const inFlight = { current: false }; + return { confirm, sendExit, refreshActiveList, inFlight }; +} + +function captureRetryAction(): RetryAction | undefined { + const call = vi.mocked(announcingToast.error).mock.calls[0]; + const options = call?.[1] as { action?: RetryAction } | undefined; + return options?.action; +} + +describe('exitRemoteSessionFromList', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('cancels without sending, refreshing, or toasting', async () => { + const { confirm, sendExit, refreshActiveList, inFlight } = createHarness(); + confirm.mockResolvedValue(false); + + await exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight }); + + expect(confirm).toHaveBeenCalledTimes(1); + expect(sendExit).not.toHaveBeenCalled(); + expect(refreshActiveList).not.toHaveBeenCalled(); + expect(announcingToast.success).not.toHaveBeenCalled(); + expect(announcingToast.error).not.toHaveBeenCalled(); + expect(inFlight.current).toBe(false); + }); + + it('sends, toasts success, refreshes, and releases the flag', async () => { + const { confirm, sendExit, refreshActiveList, inFlight } = createHarness(); + + await exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight }); + + expect(confirm).toHaveBeenCalledTimes(1); + expect(sendExit).toHaveBeenCalledTimes(1); + expect(announcingToast.success).toHaveBeenCalledWith('Session exited'); + expect(announcingToast.error).not.toHaveBeenCalled(); + expect(refreshActiveList).toHaveBeenCalledTimes(1); + expect(inFlight.current).toBe(false); + }); + + it('swallows a refresh failure after a successful send without resending', async () => { + const { confirm, sendExit, refreshActiveList, inFlight } = createHarness(); + refreshActiveList.mockRejectedValue(new Error('network down')); + + await exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight }); + + expect(sendExit).toHaveBeenCalledTimes(1); + expect(announcingToast.success).toHaveBeenCalledTimes(1); + expect(announcingToast.success).toHaveBeenCalledWith('Session exited'); + expect(announcingToast.error).not.toHaveBeenCalled(); + expect(inFlight.current).toBe(false); + }); + + it('shows a Try again toast on a retryable send failure without refreshing', async () => { + const { confirm, sendExit, refreshActiveList, inFlight } = createHarness(); + sendExit.mockRejectedValue(new Error('Invalid exit_cli response')); + + await exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight }); + + expect(sendExit).toHaveBeenCalledTimes(1); + expect(announcingToast.error).toHaveBeenCalledWith('Invalid exit_cli response', { + action: { label: 'Try again', onClick: expect.any(Function) }, + }); + expect(announcingToast.success).not.toHaveBeenCalled(); + expect(refreshActiveList).not.toHaveBeenCalled(); + expect(inFlight.current).toBe(false); + }); + + it('resends from Try again without a second confirm and holds the flag in flight', async () => { + const { confirm, sendExit, refreshActiveList, inFlight } = createHarness(); + const retryResolveRef = { resolve: undefined as (() => void) | undefined }; + sendExit.mockImplementation(async () => { + await Promise.resolve(); + if (sendExit.mock.calls.length === 1) { + throw new Error('connection reset'); + } + await new Promise(resolve => { + retryResolveRef.resolve = resolve; + }); + }); + + await exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight }); + + const action = captureRetryAction(); + if (!action) { + throw new Error('Expected retry action on the toast'); + } + + action.onClick(); + await vi.waitFor(() => { + expect(sendExit).toHaveBeenCalledTimes(2); + }); + expect(confirm).toHaveBeenCalledTimes(1); + expect(inFlight.current).toBe(true); + + retryResolveRef.resolve?.(); + await vi.waitFor(() => { + expect(inFlight.current).toBe(false); + }); + expect(announcingToast.success).toHaveBeenCalledTimes(1); + }); + + it('ignores a second Try again tap while the retry send is in flight', async () => { + const { confirm, sendExit, refreshActiveList, inFlight } = createHarness(); + const retryResolveRef = { resolve: undefined as (() => void) | undefined }; + sendExit.mockImplementation(async () => { + await Promise.resolve(); + if (sendExit.mock.calls.length === 1) { + throw new Error('connection reset'); + } + await new Promise(resolve => { + retryResolveRef.resolve = resolve; + }); + }); + + await exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight }); + + const action = captureRetryAction(); + if (!action) { + throw new Error('Expected retry action on the toast'); + } + + action.onClick(); + await vi.waitFor(() => { + expect(sendExit).toHaveBeenCalledTimes(2); + }); + expect(inFlight.current).toBe(true); + + action.onClick(); + expect(sendExit).toHaveBeenCalledTimes(2); + expect(inFlight.current).toBe(true); + + retryResolveRef.resolve?.(); + await vi.waitFor(() => { + expect(inFlight.current).toBe(false); + }); + expect(sendExit).toHaveBeenCalledTimes(2); + expect(announcingToast.success).toHaveBeenCalledTimes(1); + }); + + it('shows a non-retryable message with no action and does not resend', async () => { + const { confirm, sendExit, refreshActiveList, inFlight } = createHarness(); + sendExit.mockRejectedValue( + new Error('Remote session exit is not supported for the current session') + ); + + await exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight }); + + expect(sendExit).toHaveBeenCalledTimes(1); + expect(announcingToast.error).toHaveBeenCalledWith( + 'Remote session exit is not supported for the current session' + ); + const options = vi.mocked(announcingToast.error).mock.calls[0]?.[1] as + | { action?: RetryAction } + | undefined; + expect(options?.action).toBeUndefined(); + expect(announcingToast.success).not.toHaveBeenCalled(); + expect(refreshActiveList).not.toHaveBeenCalled(); + expect(inFlight.current).toBe(false); + }); + + it('falls back to Failed to exit session for a non-Error throw', async () => { + const { confirm, sendExit, refreshActiveList, inFlight } = createHarness(); + class NotAnError { + message = 'opaque'; + } + sendExit.mockImplementation(async () => { + await Promise.resolve(); + // oxlint-disable-next-line typescript-eslint/only-throw-error + throw new NotAnError(); + }); + + await exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight }); + + expect(sendExit).toHaveBeenCalledTimes(1); + expect(announcingToast.error).toHaveBeenCalledWith('Failed to exit session', { + action: { label: 'Try again', onClick: expect.any(Function) }, + }); + }); + + it('returns without confirming when already in flight', async () => { + const { confirm, sendExit, refreshActiveList, inFlight } = createHarness(); + inFlight.current = true; + + await exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight }); + + expect(confirm).not.toHaveBeenCalled(); + expect(sendExit).not.toHaveBeenCalled(); + expect(refreshActiveList).not.toHaveBeenCalled(); + expect(announcingToast.success).not.toHaveBeenCalled(); + expect(announcingToast.error).not.toHaveBeenCalled(); + }); + + it('releases the flag after a pending send settles', async () => { + const { confirm, sendExit, refreshActiveList, inFlight } = createHarness(); + const sendResolveRef = { resolve: undefined as (() => void) | undefined }; + sendExit.mockImplementation(async () => { + await new Promise(resolve => { + sendResolveRef.resolve = resolve; + }); + }); + + const pending = exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight }); + await vi.waitFor(() => { + expect(sendExit).toHaveBeenCalledTimes(1); + }); + expect(inFlight.current).toBe(true); + + sendResolveRef.resolve?.(); + await pending; + expect(inFlight.current).toBe(false); + expect(announcingToast.success).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/components/agents/exit-remote-session-from-list.ts b/apps/mobile/src/components/agents/exit-remote-session-from-list.ts new file mode 100644 index 0000000000..67976dcf0b --- /dev/null +++ b/apps/mobile/src/components/agents/exit-remote-session-from-list.ts @@ -0,0 +1,98 @@ +import { announcingToast } from '@/lib/a11y/announcing-toast'; + +import { confirmRemoteSessionExit } from './remote-session-exit-confirmation'; + +const SESSION_EXITED_MESSAGE = 'Session exited'; +/** + * Classifier literals copied from `exit-remote-session-with-feedback.ts`. They + * must stay in sync with that file (which pins them to the SDK source). The + * barrel import is not used here because the mobile test runner cannot resolve + * the SDK's transitive web-only `@/...` aliases. + */ +const REMOTE_SESSION_EXIT_NOT_SUPPORTED_MESSAGE = + 'Remote session exit is not supported for the current session'; +const REMOTE_SESSION_EXIT_UNAVAILABLE_MESSAGE = + 'Remote session exit is unavailable for the current session'; +const REMOTE_SESSION_EXIT_UPGRADE_PREFIX = 'Remote slash commands require a newer Kilo CLI'; +const RETRY_TOAST_LABEL = 'Try again'; +const FALLBACK_ERROR_MESSAGE = 'Failed to exit session'; + +const NON_RETRYABLE_EXIT_MESSAGES: ReadonlySet = new Set([ + REMOTE_SESSION_EXIT_NOT_SUPPORTED_MESSAGE, + REMOTE_SESSION_EXIT_UNAVAILABLE_MESSAGE, +]); + +function isNonRetryableExitError(message: string): boolean { + if (NON_RETRYABLE_EXIT_MESSAGES.has(message)) { + return true; + } + return message.startsWith(REMOTE_SESSION_EXIT_UPGRADE_PREFIX); +} + +type ExitRemoteSessionFromListInput = { + confirm: () => Promise; + sendExit: () => Promise; + refreshActiveList: () => Promise; + inFlight: { current: boolean }; +}; + +/** + * Exit a running session from the Active now list. Keeps history and never + * opens the session. The row passes `showRemoteSessionExitConfirmation` as + * `confirm`; this helper wraps `confirmRemoteSessionExit` once and owns the + * send/refresh/toast lifecycle. `inFlight` is a shared ref flag that blocks + * a second exit while one is in flight. + */ +export async function exitRemoteSessionFromList({ + confirm, + sendExit, + refreshActiveList, + inFlight, +}: Readonly): Promise { + if (inFlight.current) { + return; + } + + const runSend = async (): Promise => { + if (inFlight.current) { + return; + } + inFlight.current = true; + try { + try { + await sendExit(); + } catch (error) { + const message = error instanceof Error ? error.message : FALLBACK_ERROR_MESSAGE; + if (isNonRetryableExitError(message)) { + // Fail-closed: the SDK signalled "do not send". No CTA so the user + // sees the copy but cannot trigger another attempt. + announcingToast.error(message); + } else { + // Retryable transport / ACK failure. The retry action re-runs the + // send without a second confirm. + announcingToast.error(message, { + action: { + label: RETRY_TOAST_LABEL, + onClick: () => { + void runSend(); + }, + }, + }); + } + return; + } + + announcingToast.success(SESSION_EXITED_MESSAGE); + try { + await refreshActiveList(); + } catch { + // Swallow the refresh failure: the row already left the live set via + // the send. Do not resend `exit_cli`; the user can pull to refresh. + } + } finally { + inFlight.current = false; + } + }; + + await confirmRemoteSessionExit(confirm, runSend); +} diff --git a/apps/mobile/src/components/agents/remote-session-row.tsx b/apps/mobile/src/components/agents/remote-session-row.tsx index 110b0f37b0..eafaa7b0ba 100644 --- a/apps/mobile/src/components/agents/remote-session-row.tsx +++ b/apps/mobile/src/components/agents/remote-session-row.tsx @@ -1,11 +1,13 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; +import { useQueryClient } from '@tanstack/react-query'; import * as Haptics from 'expo-haptics'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Platform, Pressable, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { RenameModal } from '@/components/rename-modal'; import { SessionRow } from '@/components/ui/session-row'; +import { refreshActiveSessionsNow } from '@/lib/active-sessions-live-sync'; import { type ActiveSession } from '@/lib/hooks/use-agent-sessions'; import { useSessionMutations } from '@/lib/hooks/use-session-mutations'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -15,8 +17,12 @@ import { shouldShowNeedsInput, useSessionAttentionRevision, } from '@/lib/session-attention'; +import { useTRPC } from '@/lib/trpc'; +import { exitRemoteSessionFromList } from './exit-remote-session-from-list'; +import { showRemoteSessionExitConfirmation } from './remote-session-exit-alert'; import { activeSessionMetaTimestamp, + canExitSessionFromList, composeActiveSessionVisibleMeta, formatSessionTotalCost, remoteMeta, @@ -31,6 +37,7 @@ import { formatSpokenTimeAgo, sessionRowAccessibilityLabel, } from './session-row-accessibility-label'; +import { useUserWebConnection } from './user-web-connection-provider'; type RemoteSessionRowProps = { session: ActiveSession; @@ -51,6 +58,10 @@ export function RemoteSessionRow({ const { bottom } = useSafeAreaInsets(); const { showActionSheetWithOptions } = useActionSheet(); const { renameSession } = useSessionMutations(); + const queryClient = useQueryClient(); + const trpc = useTRPC(); + const connection = useUserWebConnection(); + const exitingRef = useRef(false); const title = session.title.length > 0 ? session.title : 'Untitled session'; const [renameVisible, setRenameVisible] = useState(false); const canManage = interactive; @@ -58,6 +69,7 @@ export function RemoteSessionRow({ const revision = useSessionAttentionRevision(); const raiseId = session.status; + const canExit = canExitSessionFromList(session); const needsInput = shouldShowNeedsInput({ status: session.status, raiseId, @@ -97,7 +109,33 @@ export function RemoteSessionRow({ ) : undefined; + const refreshActiveList = async () => { + if (await refreshActiveSessionsNow()) { + return; + } + await queryClient.invalidateQueries(trpc.activeSessions.list.pathFilter()); + }; + + const handleExit = () => { + void exitRemoteSessionFromList({ + confirm: showRemoteSessionExitConfirmation, + sendExit: async () => { + await connection.sendCommand( + session.id, + 'exit_cli', + { protocolVersion: 1 }, + session.connectionId + ); + }, + refreshActiveList, + inFlight: exitingRef, + }); + }; + const handleLongPress = () => { + if (exitingRef.current) { + return; + } void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); showSessionActionMenu({ showActionSheetWithOptions, @@ -114,6 +152,7 @@ export function RemoteSessionRow({ setRenameVisible(true); } }, + onExit: canExit ? handleExit : undefined, }); }; diff --git a/apps/mobile/src/components/agents/session-detail-content-helpers.test.ts b/apps/mobile/src/components/agents/session-detail-content-helpers.test.ts index 3e73f000b2..a7df0a2537 100644 --- a/apps/mobile/src/components/agents/session-detail-content-helpers.test.ts +++ b/apps/mobile/src/components/agents/session-detail-content-helpers.test.ts @@ -1,13 +1,92 @@ import { describe, expect, it, vi } from 'vitest'; -import { type MessageDeliveryState, type StoredMessage } from '@kilocode/cloud-agent-sdk'; import { + type KiloSessionId, + type MessageDeliveryState, + type StoredMessage, + type ToolPart, +} from '@kilocode/cloud-agent-sdk'; +import { + collectEmptyChildSessionIds, countInFlightMessages, + hydrateEmptyChildSessions, resolveRetryPrompt, retryMessageAndClear, } from './session-detail-content-helpers'; import { assistantMessage, userMessage } from './message-bubble-test-utils'; +const subagentSessionId = 'ses-child' as KiloSessionId; +const otherSubagentSessionId = 'ses-child-2' as KiloSessionId; + +const noChildMessages = (): StoredMessage[] => []; + +function makeToolPart(tool: string, state: ToolPart['state']): ToolPart { + return { + id: 'p1', + sessionID: 'ses-1', + messageID: 'msg-1', + type: 'tool', + tool, + callID: 'call-1', + state, + }; +} + +function makeTaskPart( + status: 'pending' | 'running' | 'completed' | 'error', + sessionId: KiloSessionId = subagentSessionId, + input: Record = {} +): ToolPart { + if (status === 'pending') { + return makeToolPart('task', { status: 'pending', input, raw: '' }); + } + if (status === 'running') { + return makeToolPart('task', { + status: 'running', + input, + time: { start: 1 }, + metadata: { sessionId }, + }); + } + if (status === 'completed') { + return makeToolPart('task', { + status: 'completed', + input, + output: 'done', + title: 'Task', + metadata: { sessionId }, + time: { start: 1, end: 2 }, + }); + } + return makeToolPart('task', { + status: 'error', + input, + error: 'failed', + metadata: { sessionId }, + time: { start: 1, end: 2 }, + }); +} + +function makeAssistantMessage(parts: ToolPart[], id = 'msg-1'): StoredMessage { + return { + info: { + id, + sessionID: 'ses-1', + role: 'assistant', + time: { created: 1 }, + parentID: 'msg-0', + modelID: 'claude', + providerID: 'anthropic', + mode: 'code', + agent: 'build', + path: { cwd: '/', root: '/' }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts, + }; +} + describe('countInFlightMessages', () => { it('excludes a failed pending row from the in-flight count', () => { const pending = new Map([ @@ -122,3 +201,102 @@ describe('resolveRetryPrompt', () => { expect(resolveRetryPrompt(assistant, [assistant])).toBeNull(); }); }); + +describe('collectEmptyChildSessionIds', () => { + it('returns [] for no messages', () => { + expect(collectEmptyChildSessionIds([], noChildMessages)).toEqual([]); + }); + + it('returns [] for a non-task tool part', () => { + const readPart = makeToolPart('read', { + status: 'completed', + input: { filePath: 'x' }, + output: 'y', + title: 'read', + metadata: {}, + time: { start: 1, end: 2 }, + }); + const messages = [makeAssistantMessage([readPart])]; + expect(collectEmptyChildSessionIds(messages, noChildMessages)).toEqual([]); + }); + + it('returns [] for a pending task with no metadata sessionId', () => { + const messages = [makeAssistantMessage([makeTaskPart('pending')])]; + expect(collectEmptyChildSessionIds(messages, noChildMessages)).toEqual([]); + }); + + it('returns the id for a completed task with empty child messages', () => { + const messages = [makeAssistantMessage([makeTaskPart('completed')])]; + expect(collectEmptyChildSessionIds(messages, noChildMessages)).toEqual([subagentSessionId]); + }); + + it('returns [] for a completed task with existing child messages', () => { + const messages = [makeAssistantMessage([makeTaskPart('completed')])]; + const getChildMessages = (id: KiloSessionId): StoredMessage[] => + id === subagentSessionId ? [makeAssistantMessage([], 'child-msg')] : []; + expect(collectEmptyChildSessionIds(messages, getChildMessages)).toEqual([]); + }); + + it('includes running and error task states when empty', () => { + const messages = [ + makeAssistantMessage([makeTaskPart('running', subagentSessionId)], 'msg-run'), + makeAssistantMessage([makeTaskPart('error', otherSubagentSessionId)], 'msg-err'), + ]; + expect(collectEmptyChildSessionIds(messages, noChildMessages)).toEqual([ + subagentSessionId, + otherSubagentSessionId, + ]); + }); + + it('returns a duplicate task id once', () => { + const messages = [ + makeAssistantMessage([makeTaskPart('completed', subagentSessionId)], 'msg-1'), + makeAssistantMessage([makeTaskPart('completed', subagentSessionId)], 'msg-2'), + ]; + expect(collectEmptyChildSessionIds(messages, noChildMessages)).toEqual([subagentSessionId]); + }); + + it('returns two different empty child ids in first-seen order', () => { + const messages = [ + makeAssistantMessage([makeTaskPart('completed', subagentSessionId)], 'msg-1'), + makeAssistantMessage([makeTaskPart('completed', otherSubagentSessionId)], 'msg-2'), + ]; + expect(collectEmptyChildSessionIds(messages, noChildMessages)).toEqual([ + subagentSessionId, + otherSubagentSessionId, + ]); + }); +}); + +describe('hydrateEmptyChildSessions', () => { + it('does not retry when ready after the first hydrate', async () => { + let status = 'loading'; + const hydrate = vi.fn(async () => { + status = 'ready'; + await Promise.resolve(); + }); + const readHydrationStatus = vi.fn(() => status); + await hydrateEmptyChildSessions([subagentSessionId], hydrate, readHydrationStatus); + expect(hydrate).toHaveBeenCalledTimes(1); + }); + + it('retries once when the first hydrate errors and the second readies', async () => { + let status = 'loading'; + const hydrate = vi.fn(async () => { + status = status === 'loading' ? 'error' : 'ready'; + await Promise.resolve(); + }); + const readHydrationStatus = vi.fn(() => status); + await hydrateEmptyChildSessions([subagentSessionId], hydrate, readHydrationStatus); + expect(hydrate).toHaveBeenCalledTimes(2); + }); + + it('stops at two hydrates when the retry also errors', async () => { + const hydrate = vi.fn(async () => { + await Promise.resolve(); + }); + const readHydrationStatus = vi.fn(() => 'error'); + await hydrateEmptyChildSessions([subagentSessionId], hydrate, readHydrationStatus); + expect(hydrate).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/mobile/src/components/agents/session-detail-content-helpers.ts b/apps/mobile/src/components/agents/session-detail-content-helpers.ts index 544eb28825..e9f518af28 100644 --- a/apps/mobile/src/components/agents/session-detail-content-helpers.ts +++ b/apps/mobile/src/components/agents/session-detail-content-helpers.ts @@ -1,6 +1,11 @@ -import { type MessageDeliveryState, type StoredMessage } from '@kilocode/cloud-agent-sdk'; +import { + type KiloSessionId, + type MessageDeliveryState, + type StoredMessage, +} from '@kilocode/cloud-agent-sdk'; -import { firstHumanText } from './part-types'; +import { getTaskToolSessionId } from './child-session-card-state'; +import { firstHumanText, isToolPart } from './part-types'; /** * Counts pending messages that are still in flight. A terminal delivery @@ -61,3 +66,49 @@ export function resolveRetryPrompt( } return null; } + +/** + * Collects child session ids that appear in task tool parts but have no + * hydrated messages yet. Ids are deduplicated and returned in first-seen + * order; a child that already has messages is skipped. + */ +export function collectEmptyChildSessionIds( + messages: readonly StoredMessage[], + getChildMessages: (childSessionId: KiloSessionId) => readonly StoredMessage[] +): KiloSessionId[] { + const seen = new Set(); + const emptyIds: KiloSessionId[] = []; + for (const message of messages) { + for (const part of message.parts) { + const childSessionId = isToolPart(part) ? getTaskToolSessionId(part) : undefined; + if (childSessionId !== undefined && !seen.has(childSessionId)) { + seen.add(childSessionId); + if (getChildMessages(childSessionId).length === 0) { + emptyIds.push(childSessionId); + } + } + } + } + return emptyIds; +} + +/** + * Hydrates each empty child session once, retrying an id exactly once when + * its first hydration reports an error status. `hydrate` never rejects on a + * fetch failure (it writes an error status), so the retry reads the status + * rather than catching a rejection. + */ +export async function hydrateEmptyChildSessions( + ids: readonly KiloSessionId[], + hydrate: (id: KiloSessionId) => Promise, + readHydrationStatus: (id: KiloSessionId) => string +): Promise { + await Promise.all( + ids.map(async id => { + await hydrate(id); + if (readHydrationStatus(id) === 'error') { + await hydrate(id); + } + }) + ); +} diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index d50be71af7..0029c83cf8 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -59,7 +59,9 @@ import { shouldShowSessionFooterRow, } from '@/components/agents/session-working-state'; import { + collectEmptyChildSessionIds, countInFlightMessages, + hydrateEmptyChildSessions, resolveRetryPrompt, retryMessageAndClear, } from '@/components/agents/session-detail-content-helpers'; @@ -169,6 +171,7 @@ export function SessionDetailContent({ }); const childSheetReleaseTimeoutRef = useRef | null>(null); const composerControlRef = useRef(null); + const childHydrateAttemptedRef = useRef>(new Set()); const clearChildSheetReleaseTimeout = useCallback(() => { if (childSheetReleaseTimeoutRef.current !== null) { @@ -458,10 +461,47 @@ export function SessionDetailContent({ void manager.switchSession(sessionId); }, [sessionId, manager]); + const store = useStore(); + + useEffect(() => { + childHydrateAttemptedRef.current = new Set(); + }, [sessionId]); + + // Hydrate child transcripts for task cards that have no messages yet, so a + // reopened parent shows each completed child's model on the card without + // opening the child sheet. An attempted id is never hydrated again for the + // same parent session; a hydrate error retries once. + useEffect(() => { + if (isLoading) { + return; + } + if (fetchedData?.kiloSessionId !== sessionId) { + return; + } + const liveGetChildMessages = store.get(manager.atoms.childMessages); + const readHydrationStatus = (id: KiloSessionId) => + store.get(manager.atoms.childSessionHydrationState)(id).status; + const emptyIds = collectEmptyChildSessionIds(messages, liveGetChildMessages).filter( + id => !childHydrateAttemptedRef.current.has(id) + ); + if (emptyIds.length === 0) { + return; + } + for (const id of emptyIds) { + childHydrateAttemptedRef.current.add(id); + } + void hydrateEmptyChildSessions( + emptyIds, + async id => { + await manager.hydrateChildSession(id); + }, + readHydrationStatus + ); + }, [isLoading, fetchedData?.kiloSessionId, sessionId, messages, manager, store]); + // Refetch the linked PR on every focus so a link, unlink, or mid-session // decision change surfaces without reopening the session. A pending review // decision gets one 4s follow-up refetch — no polling loop. - const store = useStore(); // The first focus on a session id is owned by `manager.switchSession`, which // already fetches the session metadata (including `associatedPr`). Every // later focus refetches; this ref tracks which id has been seeded. diff --git a/apps/mobile/src/components/agents/session-list-helpers.test.ts b/apps/mobile/src/components/agents/session-list-helpers.test.ts index 5b08e4093e..3b5a847f29 100644 --- a/apps/mobile/src/components/agents/session-list-helpers.test.ts +++ b/apps/mobile/src/components/agents/session-list-helpers.test.ts @@ -1,11 +1,13 @@ /* eslint-disable max-lines -- cohesive unit-test suite for session-list-helpers pure functions */ import { describe, expect, it } from 'vitest'; +import { CLOUD_AGENT_CONNECTION_ID } from '@/lib/active-sessions-live'; import { type ActiveSession } from '@/lib/hooks/use-agent-sessions'; import { parseTimestamp, timeAgo } from '@/lib/utils'; import { activeSessionMetaTimestamp, + canExitSessionFromList, composeActiveSessionSpokenMeta, composeActiveSessionVisibleMeta, excludeActiveFromGroups, @@ -334,6 +336,18 @@ describe('selectPinnedActiveSessions', () => { }); }); +describe('canExitSessionFromList', () => { + it('returns true for a real CLI connection id', () => { + expect(canExitSessionFromList(makeActive({ connectionId: 'c1' }))).toBe(true); + }); + + it('returns false for the cloud-agent sentinel connection id', () => { + expect(canExitSessionFromList(makeActive({ connectionId: CLOUD_AGENT_CONNECTION_ID }))).toBe( + false + ); + }); +}); + describe('remoteAgentLabel', () => { it('returns the platform label for cli', () => { expect(remoteAgentLabel('cli')).toBe('CLI'); diff --git a/apps/mobile/src/components/agents/session-list-helpers.ts b/apps/mobile/src/components/agents/session-list-helpers.ts index 8754ad8a79..dd8d43bdb0 100644 --- a/apps/mobile/src/components/agents/session-list-helpers.ts +++ b/apps/mobile/src/components/agents/session-list-helpers.ts @@ -1,5 +1,6 @@ import { KNOWN_PLATFORMS } from '@kilocode/app-shared/platforms'; +import { CLOUD_AGENT_CONNECTION_ID } from '@/lib/active-sessions-live'; import { type AgentSessionDateGroup } from '@/lib/agent-session-groups'; import { type ActiveSession, type StoredSession } from '@/lib/hooks/use-agent-sessions'; import { platformLabel } from '@/lib/platform-label'; @@ -288,6 +289,15 @@ export function remoteSessionEyebrowLabel(session: { return repo ? repo.toUpperCase() : remoteAgentLabel(session.createdOnPlatform); } +/** + * Whether the Active now row's long-press menu may offer Exit session. + * Cloud-agent rows carry the sentinel `connectionId` and have no CLI + * connection to receive `exit_cli`; only real CLI rows can be exited. + */ +export function canExitSessionFromList(session: { connectionId: string }): boolean { + return session.connectionId !== CLOUD_AGENT_CONNECTION_ID; +} + const KNOWN_PLATFORM_VALUES: readonly string[] = KNOWN_PLATFORMS; /** diff --git a/apps/mobile/src/components/agents/session-row-actions.test.ts b/apps/mobile/src/components/agents/session-row-actions.test.ts index 706e955350..2f42c8cba1 100644 --- a/apps/mobile/src/components/agents/session-row-actions.test.ts +++ b/apps/mobile/src/components/agents/session-row-actions.test.ts @@ -31,15 +31,18 @@ type Captured = { function openMenu(args: { onRename?: () => void; + onExit?: () => void; onDelete?: () => void; bottomInset?: number; }): Captured & { onCopySessionId: ReturnType; onRename: ReturnType | undefined; + onExit: ReturnType | undefined; onDelete: ReturnType | undefined; } { const onCopySessionId = vi.fn(() => undefined); const onRename = args.onRename ? vi.fn(() => undefined) : undefined; + const onExit = args.onExit ? vi.fn(() => undefined) : undefined; const onDelete = args.onDelete ? vi.fn(() => undefined) : undefined; const captured: { current: Captured | null } = { current: null }; @@ -52,6 +55,7 @@ function openMenu(args: { }, onCopySessionId, ...(onRename ? { onRename } : {}), + ...(onExit ? { onExit } : {}), ...(onDelete ? { onDelete } : {}), bottomInset: args.bottomInset ?? 12, }); @@ -63,6 +67,7 @@ function openMenu(args: { ...captured.current, onCopySessionId, onRename, + onExit, onDelete, }; } @@ -143,4 +148,57 @@ describe('showSessionActionMenu', () => { expect(onRename).toHaveBeenCalledTimes(1); expect(onCopySessionId).not.toHaveBeenCalled(); }); + + it('includes copy, rename, exit, cancel with destructive exit when delete is absent', () => { + const { sheetOptions } = openMenu({ + onRename: () => undefined, + onExit: () => undefined, + }); + + expect(sheetOptions.options).toEqual(['Copy session ID', 'Rename', 'Exit session', 'Cancel']); + expect(sheetOptions.cancelButtonIndex).toBe(3); + expect(sheetOptions.destructiveButtonIndex).toBe(2); + }); + + it('omits exit session when onExit is absent', () => { + const { sheetOptions } = openMenu({ + onRename: () => undefined, + onDelete: () => undefined, + }); + + expect(sheetOptions.options).toEqual(['Copy session ID', 'Rename', 'Delete session', 'Cancel']); + expect(sheetOptions.options).not.toContain('Exit session'); + }); + + it('orders copy, rename, exit, delete, cancel with destructive delete when both exist', () => { + const { sheetOptions } = openMenu({ + onRename: () => undefined, + onExit: () => undefined, + onDelete: () => undefined, + }); + + expect(sheetOptions.options).toEqual([ + 'Copy session ID', + 'Rename', + 'Exit session', + 'Delete session', + 'Cancel', + ]); + expect(sheetOptions.cancelButtonIndex).toBe(4); + expect(sheetOptions.destructiveButtonIndex).toBe(3); + }); + + it('dispatches exit at its index without copy, rename, or delete', () => { + const { onSelect, onExit, onCopySessionId, onRename, onDelete } = openMenu({ + onRename: () => undefined, + onExit: () => undefined, + onDelete: () => undefined, + }); + + onSelect(2); + expect(onExit).toHaveBeenCalledTimes(1); + expect(onCopySessionId).not.toHaveBeenCalled(); + expect(onRename).not.toHaveBeenCalled(); + expect(onDelete).not.toHaveBeenCalled(); + }); }); diff --git a/apps/mobile/src/components/agents/session-row-actions.ts b/apps/mobile/src/components/agents/session-row-actions.ts index 036aa12c7f..3cef5543b1 100644 --- a/apps/mobile/src/components/agents/session-row-actions.ts +++ b/apps/mobile/src/components/agents/session-row-actions.ts @@ -54,6 +54,12 @@ type SessionActionMenuOptions = { onCopySessionId: () => void; /** Omitted → no Rename entry. */ onRename?: () => void; + /** + * Omitted → no Exit session entry. Additive for the running-session row: + * the old menu form (Copy / Rename / Delete / Cancel) stays unchanged for + * callers that omit `onExit`. + */ + onExit?: () => void; /** Omitted → no Delete entry. */ onDelete?: () => void; /** `useSafeAreaInsets().bottom` — pads the Android custom sheet. */ @@ -62,12 +68,15 @@ type SessionActionMenuOptions = { /** * Shared session long-press menu. Builds one options list — Copy session ID, - * optional Rename, optional Delete session, Cancel — and dispatches by index. + * optional Rename, optional Exit session, optional Delete session, Cancel — + * and dispatches by index. Exit session is additive when `onExit` is passed; + * callers that omit it keep the old Copy / Rename / Delete / Cancel form. * iOS delegates to native ActionSheetIOS via @expo/react-native-action-sheet; * Android gets backdrop-tap and hardware-back dismiss from the library. */ export function showSessionActionMenu(opts: SessionActionMenuOptions): void { - const { showActionSheetWithOptions, onCopySessionId, onRename, onDelete, bottomInset } = opts; + const { showActionSheetWithOptions, onCopySessionId, onRename, onExit, onDelete, bottomInset } = + opts; const options = ['Copy session ID']; const handlers: (() => void)[] = [onCopySessionId]; @@ -76,6 +85,10 @@ export function showSessionActionMenu(opts: SessionActionMenuOptions): void { options.push('Rename'); handlers.push(onRename); } + if (onExit) { + options.push('Exit session'); + handlers.push(onExit); + } if (onDelete) { options.push('Delete session'); handlers.push(onDelete); @@ -84,7 +97,9 @@ export function showSessionActionMenu(opts: SessionActionMenuOptions): void { const cancelButtonIndex = options.length - 1; const deleteIndex = options.indexOf('Delete session'); - const destructiveButtonIndex = deleteIndex === -1 ? undefined : deleteIndex; + const exitIndex = options.indexOf('Exit session'); + // Delete wins when both exist; Exit is destructive only when Delete is absent. + const destructiveButtonIndex = [deleteIndex, exitIndex].find(index => index !== -1); showActionSheetWithOptions( {