From 05ed3cf1eb1b3d3fb8152eeb1763faf23724f36e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 16 Jul 2026 01:03:22 +0200 Subject: [PATCH 1/3] feat(mobile): preserve composer across agent prompts --- .../agents/agent-interaction-policy.test.ts | 24 ++++++ .../agents/agent-interaction-policy.ts | 16 ++++ .../agents/session-detail-content.tsx | 78 ++++++++++--------- 3 files changed, 83 insertions(+), 35 deletions(-) create mode 100644 apps/mobile/src/components/agents/agent-interaction-policy.test.ts create mode 100644 apps/mobile/src/components/agents/agent-interaction-policy.ts diff --git a/apps/mobile/src/components/agents/agent-interaction-policy.test.ts b/apps/mobile/src/components/agents/agent-interaction-policy.test.ts new file mode 100644 index 0000000000..bfdc3e4571 --- /dev/null +++ b/apps/mobile/src/components/agents/agent-interaction-policy.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { getBlockingInteraction } from './agent-interaction-policy'; + +describe('getBlockingInteraction', () => { + it('returns none without a question or permission', () => { + expect(getBlockingInteraction({ activeQuestion: null, activePermission: null })).toBe('none'); + }); + + it('returns permission when only permission is active', () => { + expect( + getBlockingInteraction({ activeQuestion: null, activePermission: { requestId: 'perm-1' } }) + ).toBe('permission'); + }); + + it('gives question priority when both are active', () => { + expect( + getBlockingInteraction({ + activeQuestion: { requestId: 'question-1' }, + activePermission: { requestId: 'perm-1' }, + }) + ).toBe('question'); + }); +}); diff --git a/apps/mobile/src/components/agents/agent-interaction-policy.ts b/apps/mobile/src/components/agents/agent-interaction-policy.ts new file mode 100644 index 0000000000..64ba3445d4 --- /dev/null +++ b/apps/mobile/src/components/agents/agent-interaction-policy.ts @@ -0,0 +1,16 @@ +type ActiveRequest = { requestId: string } | null | undefined; + +type BlockingInteraction = 'question' | 'permission' | 'none'; + +export function getBlockingInteraction(input: { + activeQuestion: ActiveRequest; + activePermission: ActiveRequest; +}): BlockingInteraction { + if (input.activeQuestion) { + return 'question'; + } + if (input.activePermission) { + return 'permission'; + } + return 'none'; +} diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 9b368ca6c7..73d4e3b26f 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -8,6 +8,7 @@ import { FlatList, KeyboardAvoidingView, Platform, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; +import { getBlockingInteraction } from '@/components/agents/agent-interaction-policy'; import { ChatComposer } from '@/components/agents/chat-composer'; import { ConnectivityBanner } from '@/components/agents/connectivity-banner'; import { MessageBubble } from '@/components/agents/message-bubble'; @@ -66,6 +67,7 @@ import { type ModelPickerSelection, type ModelPickerSelectionScope, } from '@/lib/picker-bridge'; +import { cn } from '@/lib/utils'; type SessionDetailContentProps = { sessionId: KiloSessionId; @@ -402,14 +404,15 @@ export function SessionDetailContent({ const title = fetchedData?.kiloSessionId === sessionId ? (fetchedData.title ?? 'Session') : 'Session'; const requiresModel = Boolean(fetchedData?.cloudAgentSessionId); + const blockingInteraction = getBlockingInteraction({ activeQuestion, activePermission }); + const hasBlockingInteraction = blockingInteraction !== 'none'; const isComposerDisabled = isReadOnly || !canSend || shouldShowLoading || Boolean(error) || - Boolean(activeQuestion) || + hasBlockingInteraction || (requiresModel && !currentModel); - const showInteractionCards = activeQuestion ?? activePermission; const composerPlaceholder = (cloudStatus && COMPOSER_PLACEHOLDERS[cloudStatus.type]) ?? 'Message...'; const keyboardContainerKind = getSessionKeyboardContainerKind(Platform.OS); @@ -506,7 +509,7 @@ export function SessionDetailContent({ <> {renderContent()} - {activeQuestion ? ( + {blockingInteraction === 'question' && activeQuestion ? ( { @@ -519,7 +522,7 @@ export function SessionDetailContent({ /> ) : null} - {activePermission ? ( + {blockingInteraction === 'permission' && activePermission ? ( ) : null} - {!showInteractionCards && - (isReadOnly && messages.length > 0 ? ( - - - This is a read-only session - - - ) : ( - <> - - - - - ))} + {isReadOnly && messages.length > 0 && !hasBlockingInteraction ? ( + + + This is a read-only session + + + ) : null} + + {!isReadOnly || messages.length === 0 ? ( + + + + + + ) : null} ); } From c8572ad862be701ebd60f7e76e36c795da6bff6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 16 Jul 2026 01:03:57 +0200 Subject: [PATCH 2/3] feat(mobile): report active agent session presence --- .../agents/session-detail-content.tsx | 10 +++++++ .../hooks/use-cli-session-presence.test.ts | 27 +++++++++++++++++++ .../hooks/use-cli-session-presence.ts | 19 +++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-cli-session-presence.test.ts create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-cli-session-presence.ts diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 73d4e3b26f..2997ced9c7 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -33,6 +33,10 @@ import { } from '@/components/agents/session-working-state'; import { EmptyState } from '@/components/empty-state'; import { AppAwareKeyboardPaddingView } from '@/components/kilo-chat/app-aware-keyboard-padding'; +import { + resolveLoadedCliSessionPresenceId, + useCliSessionPresence, +} from '@/components/kilo-chat/hooks/use-cli-session-presence'; import { useInteractionHandlers } from '@/components/agents/use-interaction-handlers'; import { useSessionAutoScroll } from '@/components/agents/use-session-auto-scroll'; import { useSessionConfigSync } from '@/components/agents/use-session-config-sync'; @@ -137,6 +141,12 @@ export function SessionDetailContent({ const organizationId = fetchedData?.organizationId ?? undefined; + const presenceSessionId = resolveLoadedCliSessionPresenceId( + sessionId, + fetchedData?.kiloSessionId + ); + useCliSessionPresence(presenceSessionId); + const { saveModel: savePersistedModel } = usePersistedAgentModel(); const { setLastSelected: persistServerLastSelected } = useModelPreferences(organizationId); const { defaultExpanded: reasoningDefaultExpanded } = useReasoningPreference(); diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-cli-session-presence.test.ts b/apps/mobile/src/components/kilo-chat/hooks/use-cli-session-presence.test.ts new file mode 100644 index 0000000000..4ff43b4c9a --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-cli-session-presence.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { resolveLoadedCliSessionPresenceId } from './use-cli-session-presence'; + +// Mock the transitive react-native import (Flow source trips rolldown's +// SSR transform) and the bare minimum of useAppActiveAndFocused's other +// dependency. We only exercise resolveLoadedCliSessionPresenceId here. +vi.mock('react-native', () => ({ + AppState: { addEventListener: vi.fn(() => ({ remove: vi.fn() })) }, +})); + +vi.mock('expo-router', () => ({ + useFocusEffect: vi.fn(), +})); + +describe('resolveLoadedCliSessionPresenceId', () => { + it('returns the route ID only after matching session data loads', () => { + expect(resolveLoadedCliSessionPresenceId('route-1', 'route-1')).toBe('route-1'); + }); + + it.each([undefined, null, 'other-session'])( + 'does not claim presence for loaded ID %s', + loadedSessionId => { + expect(resolveLoadedCliSessionPresenceId('route-1', loadedSessionId)).toBeUndefined(); + } + ); +}); diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-cli-session-presence.ts b/apps/mobile/src/components/kilo-chat/hooks/use-cli-session-presence.ts new file mode 100644 index 0000000000..20071eae37 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-cli-session-presence.ts @@ -0,0 +1,19 @@ +import { presenceContextForCliSession } from '@kilocode/event-service'; +import { usePresenceSubscription } from '@kilocode/kilo-chat-hooks'; + +import { useAppActiveAndFocused } from './use-app-active-and-focused'; + +export function resolveLoadedCliSessionPresenceId( + routeSessionId: string, + loadedSessionId: string | null | undefined +): string | undefined { + return loadedSessionId === routeSessionId ? routeSessionId : undefined; +} + +export function useCliSessionPresence(sessionId: string | undefined): void { + const activeAndFocused = useAppActiveAndFocused(); + usePresenceSubscription( + sessionId ? presenceContextForCliSession(sessionId) : null, + Boolean(sessionId) && activeAndFocused + ); +} From ab96ab9dff314244f9929b81e2ad61448654a021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 16 Jul 2026 01:04:04 +0200 Subject: [PATCH 3/3] feat(mobile): respond to inline agent suggestions --- .../components/agents/suggest-tool-card.tsx | 44 ++++++ .../agents/suggestion-card-state.test.ts | 40 ++++++ .../agents/suggestion-card-state.ts | 38 +++++ .../src/components/agents/suggestion-card.tsx | 131 ++++++++++++++++++ .../components/agents/tool-part-renderer.tsx | 4 + 5 files changed, 257 insertions(+) create mode 100644 apps/mobile/src/components/agents/suggest-tool-card.tsx create mode 100644 apps/mobile/src/components/agents/suggestion-card-state.test.ts create mode 100644 apps/mobile/src/components/agents/suggestion-card-state.ts create mode 100644 apps/mobile/src/components/agents/suggestion-card.tsx diff --git a/apps/mobile/src/components/agents/suggest-tool-card.tsx b/apps/mobile/src/components/agents/suggest-tool-card.tsx new file mode 100644 index 0000000000..ed9a2b0a1c --- /dev/null +++ b/apps/mobile/src/components/agents/suggest-tool-card.tsx @@ -0,0 +1,44 @@ +import { useAtomValue } from 'jotai'; +import { Sparkles } from 'lucide-react-native'; +import { type ToolPart } from 'cloud-agent-sdk'; + +import { useSessionManager } from '@/components/agents/session-provider'; + +import { resolveSuggestionPresentation } from './suggestion-card-state'; +import { SuggestionCard } from './suggestion-card'; +import { ToolCardShell } from './tool-card-shell'; + +export function SuggestToolCard({ part }: Readonly<{ part: ToolPart }>) { + const manager = useSessionManager(); + const activeSuggestion = useAtomValue(manager.atoms.activeSuggestion); + const presentation = resolveSuggestionPresentation( + part.state.status, + part.callID, + activeSuggestion + ); + + if (presentation === 'interactive' && activeSuggestion) { + return ( + { + await manager.acceptSuggestion(activeSuggestion.requestId, index); + }} + onDismiss={async () => { + await manager.dismissSuggestion(activeSuggestion.requestId); + }} + /> + ); + } + + return ( + + ); +} diff --git a/apps/mobile/src/components/agents/suggestion-card-state.test.ts b/apps/mobile/src/components/agents/suggestion-card-state.test.ts new file mode 100644 index 0000000000..b0dfe4bcce --- /dev/null +++ b/apps/mobile/src/components/agents/suggestion-card-state.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { + createSuggestionActionLock, + resolveSuggestionPresentation, + suggestionActionError, +} from './suggestion-card-state'; + +describe('resolveSuggestionPresentation', () => { + const suggestion = { requestId: 'sug-1', callId: 'call-1' }; + + it('is interactive only for a pending matching call', () => { + expect(resolveSuggestionPresentation('pending', 'call-1', suggestion)).toBe('interactive'); + expect(resolveSuggestionPresentation('running', 'call-1', suggestion)).toBe('interactive'); + }); + + it.each([ + ['completed', 'call-1'], + ['error', 'call-1'], + ['pending', 'other-call'], + ['pending', undefined], + ] as const)('is compact for %s / %s', (status, callId) => { + expect(resolveSuggestionPresentation(status, callId, suggestion)).toBe('compact'); + }); +}); + +describe('createSuggestionActionLock', () => { + it('rejects duplicate acquisition until released', () => { + const lock = createSuggestionActionLock(); + expect(lock.tryAcquire()).toBe(true); + expect(lock.tryAcquire()).toBe(false); + lock.release(); + expect(lock.tryAcquire()).toBe(true); + }); +}); + +it('uses fixed safe error copy', () => { + expect(suggestionActionError('accept')).toBe("Couldn't apply this suggestion. Try again."); + expect(suggestionActionError('dismiss')).toBe("Couldn't dismiss this suggestion. Try again."); +}); diff --git a/apps/mobile/src/components/agents/suggestion-card-state.ts b/apps/mobile/src/components/agents/suggestion-card-state.ts new file mode 100644 index 0000000000..66745d2ed6 --- /dev/null +++ b/apps/mobile/src/components/agents/suggestion-card-state.ts @@ -0,0 +1,38 @@ +type ToolStatus = 'pending' | 'running' | 'completed' | 'error'; +type ActiveSuggestionIdentity = { requestId: string; callId?: string } | null; + +export function resolveSuggestionPresentation( + status: ToolStatus, + callId: string | undefined, + suggestion: ActiveSuggestionIdentity +): 'interactive' | 'compact' { + const pending = status === 'pending' || status === 'running'; + return pending && suggestion?.callId !== undefined && suggestion.callId === callId + ? 'interactive' + : 'compact'; +} + +export function createSuggestionActionLock(): { + tryAcquire: () => boolean; + release: () => void; +} { + let held = false; + return { + tryAcquire: () => { + if (held) { + return false; + } + held = true; + return true; + }, + release: () => { + held = false; + }, + }; +} + +export function suggestionActionError(kind: 'accept' | 'dismiss'): string { + return kind === 'accept' + ? "Couldn't apply this suggestion. Try again." + : "Couldn't dismiss this suggestion. Try again."; +} diff --git a/apps/mobile/src/components/agents/suggestion-card.tsx b/apps/mobile/src/components/agents/suggestion-card.tsx new file mode 100644 index 0000000000..38f0065d4d --- /dev/null +++ b/apps/mobile/src/components/agents/suggestion-card.tsx @@ -0,0 +1,131 @@ +import { useRef, useState } from 'react'; +import { View } from 'react-native'; +import * as Haptics from 'expo-haptics'; +import { Sparkles } from 'lucide-react-native'; +import { type StandaloneSuggestion, type SuggestionAction } from 'cloud-agent-sdk'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { cn } from '@/lib/utils'; + +import { createSuggestionActionLock, suggestionActionError } from './suggestion-card-state'; + +type SuggestionCardProps = { + text: string; + actions: StandaloneSuggestion['actions']; + onAccept: (index: number) => Promise; + onDismiss: () => Promise; +}; + +type PendingState = { kind: 'accept'; index: number } | { kind: 'dismiss' }; + +export function SuggestionCard({ + text, + actions, + onAccept, + onDismiss, +}: Readonly) { + const colors = useThemeColors(); + const lockRef = useRef(createSuggestionActionLock()); + const [pending, setPending] = useState(null); + const [error, setError] = useState(null); + + async function handleAccept(index: number) { + if (!lockRef.current.tryAcquire()) { + return; + } + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + setPending({ kind: 'accept', index }); + setError(null); + try { + await onAccept(index); + } catch { + lockRef.current.release(); + setPending(null); + setError(suggestionActionError('accept')); + } + } + + async function handleDismiss() { + if (!lockRef.current.tryAcquire()) { + return; + } + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + setPending({ kind: 'dismiss' }); + setError(null); + try { + await onDismiss(); + } catch { + lockRef.current.release(); + setPending(null); + setError(suggestionActionError('dismiss')); + } + } + + const isPending = pending !== null; + + return ( + + + + Suggestion + + + + {text} + + {error ? ( + + {error} + + ) : null} + + {actions.length > 0 ? ( + + {actions.map((action: SuggestionAction, index: number) => { + const isLoading = pending?.kind === 'accept' && pending.index === index; + return ( + + + {action.description ? ( + + {action.description} + + ) : null} + + ); + })} + + ) : null} + + + + + ); +} diff --git a/apps/mobile/src/components/agents/tool-part-renderer.tsx b/apps/mobile/src/components/agents/tool-part-renderer.tsx index 8121340921..d17e7c3a72 100644 --- a/apps/mobile/src/components/agents/tool-part-renderer.tsx +++ b/apps/mobile/src/components/agents/tool-part-renderer.tsx @@ -19,6 +19,7 @@ import { WebSearchToolCard, WriteToolCard, } from './tool-cards'; +import { SuggestToolCard } from './suggest-tool-card'; type ToolPartRendererProps = { part: ToolPart; @@ -84,6 +85,9 @@ export function ToolPartRenderer({ case 'task': { return ; } + case 'suggest': { + return ; + } default: { return ; }