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
24 changes: 24 additions & 0 deletions apps/mobile/src/components/agents/agent-interaction-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
16 changes: 16 additions & 0 deletions apps/mobile/src/components/agents/agent-interaction-policy.ts
Original file line number Diff line number Diff line change
@@ -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';
}
88 changes: 53 additions & 35 deletions apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -66,6 +71,7 @@ import {
type ModelPickerSelection,
type ModelPickerSelectionScope,
} from '@/lib/picker-bridge';
import { cn } from '@/lib/utils';

type SessionDetailContentProps = {
sessionId: KiloSessionId;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -506,7 +519,7 @@ export function SessionDetailContent({
<>
<View className="flex-1">{renderContent()}</View>

{activeQuestion ? (
{blockingInteraction === 'question' && activeQuestion ? (
<QuestionCard
questions={activeQuestion.questions}
onAnswer={answers => {
Expand All @@ -519,7 +532,7 @@ export function SessionDetailContent({
/>
) : null}

{activePermission ? (
{blockingInteraction === 'permission' && activePermission ? (
<PermissionCard
permission={activePermission.permission}
patterns={activePermission.patterns}
Expand All @@ -531,37 +544,42 @@ export function SessionDetailContent({
/>
) : null}

{!showInteractionCards &&
(isReadOnly && messages.length > 0 ? (
<View className="border-t border-border bg-secondary px-4 py-3">
<Text className="text-center text-sm text-muted-foreground">
This is a read-only session
</Text>
</View>
) : (
<>
<ModelPickerSelectionScopeProvider
selectionScope={modelPickerSelectionScope}
isSelectionCurrent={isModelPickerSelectionCurrent}
>
<ChatComposer
onSend={handleSend}
onStop={handleStop}
disabled={isComposerDisabled}
isStreaming={isStreaming}
placeholder={composerPlaceholder}
mode={currentMode}
onModeChange={setCurrentMode}
model={currentModel}
variant={currentVariant}
modelOptions={modelOptions}
onModelSelect={handleModelSelect}
organizationId={organizationId}
attachmentsEnabled={supportsAttachments}
/>
</ModelPickerSelectionScopeProvider>
</>
))}
{isReadOnly && messages.length > 0 && !hasBlockingInteraction ? (
<View className="border-t border-border bg-secondary px-4 py-3">
<Text className="text-center text-sm text-muted-foreground">
This is a read-only session
</Text>
</View>
) : null}

{!isReadOnly || messages.length === 0 ? (
<View
className={cn(hasBlockingInteraction && 'hidden')}
accessibilityElementsHidden={hasBlockingInteraction}
importantForAccessibility={hasBlockingInteraction ? 'no-hide-descendants' : 'auto'}
>
<ModelPickerSelectionScopeProvider
selectionScope={modelPickerSelectionScope}
isSelectionCurrent={isModelPickerSelectionCurrent}
>
<ChatComposer
onSend={handleSend}
onStop={handleStop}
disabled={isComposerDisabled}
isStreaming={isStreaming}
placeholder={composerPlaceholder}
mode={currentMode}
onModeChange={setCurrentMode}
model={currentModel}
variant={currentVariant}
modelOptions={modelOptions}
onModelSelect={handleModelSelect}
organizationId={organizationId}
attachmentsEnabled={supportsAttachments}
/>
</ModelPickerSelectionScopeProvider>
</View>
) : null}
</>
);
}
Expand Down
44 changes: 44 additions & 0 deletions apps/mobile/src/components/agents/suggest-tool-card.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SuggestionCard
key={activeSuggestion.requestId}
text={activeSuggestion.text}
actions={activeSuggestion.actions}
onAccept={async index => {
await manager.acceptSuggestion(activeSuggestion.requestId, index);
}}
onDismiss={async () => {
await manager.dismissSuggestion(activeSuggestion.requestId);
}}
/>
);
}

return (
<ToolCardShell
icon={Sparkles}
title="Suggestion"
subtitle={part.state.status === 'error' ? 'Suggestion dismissed' : 'Suggestion'}
status={part.state.status}
/>
);
}
40 changes: 40 additions & 0 deletions apps/mobile/src/components/agents/suggestion-card-state.test.ts
Original file line number Diff line number Diff line change
@@ -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.");
});
38 changes: 38 additions & 0 deletions apps/mobile/src/components/agents/suggestion-card-state.ts
Original file line number Diff line number Diff line change
@@ -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.";
}
Loading