Skip to content
Open
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
67 changes: 67 additions & 0 deletions apps/mobile/src/components/agents/interaction-surface.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';

import { hasActiveInteraction, pickActiveInteractionSurface } from './interaction-surface';

describe('pickActiveInteractionSurface', () => {
it('returns none when no interaction is active', () => {
expect(
pickActiveInteractionSurface({
activeQuestion: null,
activePermission: null,
activeSuggestion: null,
})
).toEqual({ kind: 'none' });
});

it('prefers question over permission and suggestion', () => {
expect(
pickActiveInteractionSurface({
activeQuestion: { requestId: 'q-1' },
activePermission: { requestId: 'p-1' },
activeSuggestion: { requestId: 's-1' },
})
).toEqual({ kind: 'question' });
});

it('prefers permission over suggestion', () => {
expect(
pickActiveInteractionSurface({
activeQuestion: null,
activePermission: { requestId: 'p-1' },
activeSuggestion: { requestId: 's-1' },
})
).toEqual({ kind: 'permission' });
});

it('returns suggestion when no question or permission is active', () => {
expect(
pickActiveInteractionSurface({
activeQuestion: null,
activePermission: null,
activeSuggestion: { requestId: 's-1' },
})
).toEqual({ kind: 'suggestion' });
});
});

describe('hasActiveInteraction', () => {
it('is true when only suggestion is active so the composer is hidden', () => {
expect(
hasActiveInteraction({
activeQuestion: null,
activePermission: null,
activeSuggestion: { requestId: 's-1' },
})
).toBe(true);
});

it('is false when no interaction is active so the composer can render', () => {
expect(
hasActiveInteraction({
activeQuestion: null,
activePermission: null,
activeSuggestion: null,
})
).toBe(false);
});
});
42 changes: 42 additions & 0 deletions apps/mobile/src/components/agents/interaction-surface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Pure derivation of the active interaction surface for `SessionDetailContent`.
*
* Exactly one interaction surface is visible at a time. The precedence is
* fixed: question > permission > suggestion. The composer is hidden for
* any active interaction so the user can only act on the surface they are
* being asked to respond to.
*/

type ActiveInteractionLike = { requestId: string } | null | undefined;

export function hasActiveInteraction(args: {
activeQuestion: ActiveInteractionLike;
activePermission: ActiveInteractionLike;
activeSuggestion: ActiveInteractionLike;
}): boolean {
return (
Boolean(args.activeQuestion) || Boolean(args.activePermission) || Boolean(args.activeSuggestion)
);
}

/**
* Returns the precedence winner for which interaction card to render.
* `kind: 'none'` means no interaction card should render and the composer
* is allowed (subject to its other disabled flags).
*/
export function pickActiveInteractionSurface(args: {
activeQuestion: ActiveInteractionLike;
activePermission: ActiveInteractionLike;
activeSuggestion: ActiveInteractionLike;
}): { kind: 'question' } | { kind: 'permission' } | { kind: 'suggestion' } | { kind: 'none' } {
if (args.activeQuestion) {
return { kind: 'question' };
}
if (args.activePermission) {
return { kind: 'permission' };
}
if (args.activeSuggestion) {
return { kind: 'suggestion' };
}
return { kind: 'none' };
}
70 changes: 65 additions & 5 deletions apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { MessageBubble } from '@/components/agents/message-bubble';
import { ModelPickerSelectionScopeProvider } from '@/components/agents/model-selector';
import { PermissionCard } from '@/components/agents/permission-card';
import { QuestionCard } from '@/components/agents/question-card';
import { SuggestionCard } from '@/components/agents/suggestion-card';
import { getSessionKeyboardContainerKind } from '@/components/agents/session-keyboard-container-state';
import { useSessionManager } from '@/components/agents/session-provider';
import { SessionStatusIndicator } from '@/components/agents/session-status-indicator';
Expand All @@ -23,11 +24,16 @@ import {
} from '@/components/agents/session-working-state';
import { EmptyState } from '@/components/empty-state';
import { AppAwareKeyboardPaddingView } from '@/components/kilo-chat/app-aware-keyboard-padding';
import { useAgentSessionPresence } from '@/components/kilo-chat/hooks/use-agent-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';
import { WorkingIndicator } from '@/components/agents/working-indicator';
import { ChildSessionSheet } from '@/components/agents/child-session-sheet';
import {
hasActiveInteraction,
pickActiveInteractionSurface,
} from '@/components/agents/interaction-surface';
import { PartRenderer } from '@/components/agents/part-renderer';
import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
Expand Down Expand Up @@ -91,6 +97,7 @@ export function SessionDetailContent({
const supportsAttachments = useAtomValue(manager.atoms.supportsAttachments);
const activeQuestion = useAtomValue(manager.atoms.activeQuestion);
const activePermission = useAtomValue(manager.atoms.activePermission);
const activeSuggestion = useAtomValue(manager.atoms.activeSuggestion);
const totalCost = useAtomValue(manager.atoms.totalCost);
const getChildMessages = useAtomValue(manager.atoms.childMessages);
const getChildSessionHydrationState = useAtomValue(manager.atoms.childSessionHydrationState);
Expand All @@ -103,6 +110,12 @@ export function SessionDetailContent({
const { isConnected } = useAppLifecycle();
const { bottom } = useSafeAreaInsets();

// Hold exact-session presence while the user is actively viewing this
// session. The hook gates on app-active + route-focused and releases the
// context as soon as we navigate away so notifications for this session
// (and others) resume correctly.
useAgentSessionPresence(sessionId);

const analyticsSurface: AnalyticsSurface = fetchedData?.cloudAgentSessionId
? 'cloud-agent'
: 'remote-session';
Expand Down Expand Up @@ -271,6 +284,26 @@ export function SessionDetailContent({
}
}, [manager]);

// SuggestionCard owns safe error copy. We forward the manager call and
// let the error propagate to the card, which renders the friendly retry
// text; we deliberately avoid a second toast here.
const handleAcceptSuggestion = useCallback(
async (index: number) => {
if (!activeSuggestion) {
return;
}
await manager.acceptSuggestion(activeSuggestion.requestId, index);
},
[activeSuggestion, manager]
);

const handleDismissSuggestion = useCallback(async () => {
if (!activeSuggestion) {
return;
}
await manager.dismissSuggestion(activeSuggestion.requestId);
}, [activeSuggestion, manager]);

const handleBackToSessions = useCallback(() => {
router.replace('/(app)/(tabs)/(2_agents)' as Href);
}, [router]);
Expand Down Expand Up @@ -331,14 +364,27 @@ export function SessionDetailContent({
const title =
fetchedData?.kiloSessionId === sessionId ? (fetchedData.title ?? 'Session') : 'Session';
const requiresModel = Boolean(fetchedData?.cloudAgentSessionId);
// Exactly one interaction surface is ever visible: question wins, then
// permission, then suggestion. The composer is hidden while any of them
// is active so the user can only act on the surface they're being asked
// to respond to.
const hasInteraction = hasActiveInteraction({
activeQuestion,
activePermission,
activeSuggestion,
});
const activeInteractionSurface = pickActiveInteractionSurface({
activeQuestion,
activePermission,
activeSuggestion,
});
const isComposerDisabled =
isReadOnly ||
!canSend ||
shouldShowLoading ||
Boolean(error) ||
Boolean(activeQuestion) ||
hasInteraction ||
(requiresModel && !currentModel);
const showInteractionCards = activeQuestion ?? activePermission;
const composerPlaceholder =
(cloudStatus && COMPOSER_PLACEHOLDERS[cloudStatus.type]) ?? 'Message...';
const keyboardContainerKind = getSessionKeyboardContainerKind(Platform.OS);
Expand Down Expand Up @@ -429,7 +475,7 @@ export function SessionDetailContent({
<>
<View className="flex-1">{renderContent()}</View>

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

{activePermission ? (
{activeInteractionSurface.kind === 'permission' && activePermission ? (
<PermissionCard
permission={activePermission.permission}
patterns={activePermission.patterns}
Expand All @@ -454,7 +500,21 @@ export function SessionDetailContent({
/>
) : null}

{!showInteractionCards &&
{activeInteractionSurface.kind === 'suggestion' && activeSuggestion ? (
<SuggestionCard
Comment thread
iscekic marked this conversation as resolved.
key={activeSuggestion.requestId}
text={activeSuggestion.text}
actions={activeSuggestion.actions}
onAccept={async index => {
await handleAcceptSuggestion(index);
}}
onDismiss={async () => {
await handleDismissSuggestion();
}}
/>
) : null}

{!hasInteraction &&
(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">
Expand Down
Loading