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..2997ced9c7 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';
@@ -32,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';
@@ -66,6 +71,7 @@ import {
type ModelPickerSelection,
type ModelPickerSelectionScope,
} from '@/lib/picker-bridge';
+import { cn } from '@/lib/utils';
type SessionDetailContentProps = {
sessionId: KiloSessionId;
@@ -135,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();
@@ -402,14 +414,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 +519,7 @@ export function SessionDetailContent({
<>
{renderContent()}
- {activeQuestion ? (
+ {blockingInteraction === 'question' && activeQuestion ? (
{
@@ -519,7 +532,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}
>
);
}
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 ;
}
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
+ );
+}