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
151 changes: 151 additions & 0 deletions apps/mobile/src/components/agents/preparation-group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { useEffect, useState } from 'react';
import { ActivityIndicator, Pressable, View } from 'react-native';
import { AlertCircle, Check, ChevronRight, Terminal } from 'lucide-react-native';
import { type PreparationAttempt, type PreparationStepSnapshot } from 'cloud-agent-sdk';

import { Text } from '@/components/ui/text';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';

export function PreparationGroup({ attempt }: { attempt: PreparationAttempt }) {
const [expanded, setExpanded] = useState(attempt.status !== 'completed');
const colors = useThemeColors();
useEffect(() => {
setExpanded(attempt.status !== 'completed');
}, [attempt.id, attempt.status]);
const title = attemptTitle(attempt.status);
return (
<View className="mx-4 my-2 overflow-hidden rounded-md border border-border bg-card">
<Pressable
onPress={() => {
setExpanded(value => !value);
}}
accessibilityRole="button"
accessibilityLabel={title}
accessibilityState={{ expanded }}
className="flex-row items-center gap-2 px-3 py-3 active:bg-secondary"
>
<ChevronRight
size={16}
color={colors.mutedForeground}
style={{ transform: [{ rotate: expanded ? '90deg' : '0deg' }] }}
/>
<AttemptIcon status={attempt.status} />
<Text className="text-sm font-medium">{title}</Text>
</Pressable>
{expanded && (
<View className="gap-2 border-t border-border px-3 py-2">
{attempt.safeError && attempt.steps.length === 0 ? (
<Text selectable className="text-sm text-destructive">
{attempt.safeError}
</Text>
) : null}
{attempt.steps.map(step => (
<PreparationStepRow key={step.id} step={step} />
))}
</View>
)}
</View>
);
}

function AttemptIcon({ status }: { status: PreparationAttempt['status'] }) {
const colors = useThemeColors();
if (status === 'running') {
return <ActivityIndicator size="small" color={colors.mutedForeground} />;
}
if (status === 'completed') {
return <Check size={16} color={colors.good} />;
}
return <AlertCircle size={16} color={colors.destructive} />;
}

function attemptTitle(status: PreparationAttempt['status']): string {
if (status === 'running') {
return 'Preparing environment';
}
if (status === 'completed') {
return 'Preparation complete';
}
return 'Preparation failed';
}

function PreparationStepRow({ step }: { step: PreparationStepSnapshot }) {
const [expanded, setExpanded] = useState(step.status !== 'completed');
const colors = useThemeColors();
useEffect(() => {
setExpanded(step.status !== 'completed');
}, [step.id, step.status]);
const hasDetails = [
step.command,
step.latestDetail,
step.outputTail,
step.safeError,
step.exitCode,
].some(value => value !== undefined && value !== '');
const label =
step.kind === 'setup_command' && step.commandIndex !== undefined
? `Setup command ${step.commandIndex + 1}${step.commandCount ? ` of ${step.commandCount}` : ''}`
: step.label;
return (
<View className="overflow-hidden rounded border border-border">
<Pressable
disabled={!hasDetails}
onPress={() => {
setExpanded(value => !value);
}}
accessibilityRole={hasDetails ? 'button' : undefined}
accessibilityLabel={label}
accessibilityState={hasDetails ? { expanded } : undefined}
className="flex-row items-center gap-2 px-2 py-2.5 active:bg-secondary"
>
{hasDetails ? (
<ChevronRight
size={14}
color={colors.mutedForeground}
style={{ transform: [{ rotate: expanded ? '90deg' : '0deg' }] }}
/>
) : (
<View className="w-3.5" />
)}
{step.kind === 'setup_command' ? (
<Terminal size={14} color={colors.mutedForeground} />
) : (
<AttemptIcon status={step.status} />
)}
<Text className="min-w-0 flex-1 text-sm" numberOfLines={1}>
{label}
</Text>
</Pressable>
{expanded && hasDetails ? (
<View className="gap-2 border-t border-border px-3 py-2">
{step.command ? (
<Text selectable className="rounded bg-secondary p-2 font-mono text-xs">
{step.command}
</Text>
) : null}
{step.latestDetail ? (
<Text className="text-sm text-muted-foreground">{step.latestDetail}</Text>
) : null}
{step.safeError ? (
<Text selectable className="text-sm text-destructive">
{step.safeError}
</Text>
) : null}
{step.exitCode !== undefined ? (
<Text className="text-xs text-muted-foreground">Exit code: {step.exitCode}</Text>
) : null}
{step.outputTail ? (
<>
{step.outputTruncated ? (
<Text className="text-xs text-muted-foreground">Earlier output omitted</Text>
) : null}
<Text selectable className="rounded bg-secondary p-2 font-mono text-xs">
{step.outputTail}
</Text>
</>
) : null}
</View>
) : null}
</View>
);
}
79 changes: 61 additions & 18 deletions apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
/* eslint-disable max-lines -- Session orchestration and its render paths are kept together. */
import { type CloudStatus, type KiloSessionId, type StoredMessage } from 'cloud-agent-sdk';
import {
type CloudStatus,
type KiloSessionId,
type PreparationAttempt,
type StoredMessage,
} from 'cloud-agent-sdk';
import { type Href, useRouter } from 'expo-router';
import { useAtomValue } from 'jotai';
import { MessageSquare } from 'lucide-react-native';
Expand All @@ -26,6 +31,7 @@ import {
import { SessionContextSheet } from '@/components/agents/session-context-sheet';
import { useSessionManager } from '@/components/agents/session-provider';
import { SessionStatusIndicator } from '@/components/agents/session-status-indicator';
import { PreparationGroup } from '@/components/agents/preparation-group';
import {
shouldShowAgentWorkingIndicator,
shouldShowFooterWorkingIndicator,
Expand Down Expand Up @@ -72,6 +78,10 @@ type SessionDetailContentProps = {
openedVia?: 'push' | 'app';
};

type TranscriptItem =
| { type: 'message'; message: StoredMessage }
| { type: 'preparation'; attempt: PreparationAttempt };

const COMPOSER_PLACEHOLDERS: Partial<Record<CloudStatus['type'], string>> = {
preparing: 'Setting up environment...',
finalizing: 'Wrapping up...',
Expand All @@ -96,6 +106,7 @@ export function SessionDetailContent({
const isStreaming = useAtomValue(manager.atoms.isStreaming);
const statusIndicator = useAtomValue(manager.atoms.statusIndicator);
const cloudStatus = useAtomValue(manager.atoms.cloudStatus);
const preparationAttempts = useAtomValue(manager.atoms.preparationAttempts);
const canSend = useAtomValue(manager.atoms.canSend);
const isReadOnly = useAtomValue(manager.atoms.isReadOnly);
const supportsAttachments = useAtomValue(manager.atoms.supportsAttachments);
Expand Down Expand Up @@ -242,7 +253,10 @@ export function SessionDetailContent({
handleScrollEndDrag,
handleMomentumScrollBegin,
handleMomentumScrollEnd,
} = useSessionAutoScroll<StoredMessage>({ itemCount: messages.length, resetKey: sessionId });
} = useSessionAutoScroll<TranscriptItem>({
itemCount: messages.length + preparationAttempts.length,
resetKey: sessionId,
});

const viewTrackedRef = useRef<string | null>(null);
useEffect(() => {
Expand Down Expand Up @@ -297,13 +311,13 @@ export function SessionDetailContent({
sessionId,
]);

const lastAssistantIndex = useMemo(() => {
const lastAssistantMessageId = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i]?.info.role === 'assistant') {
return i;
return messages[i]?.info.id ?? null;
}
}
return -1;
return null;
}, [messages]);

const handleOpenChildSession = useCallback(
Expand All @@ -314,19 +328,46 @@ export function SessionDetailContent({
[manager]
);

const transcript = useMemo<TranscriptItem[]>(() => {
const byMessageId = new Map<string, PreparationAttempt[]>();
for (const attempt of preparationAttempts) {
const attempts = byMessageId.get(attempt.triggerMessageId) ?? [];
byMessageId.set(attempt.triggerMessageId, [...attempts, attempt]);
}
const items: TranscriptItem[] = [];
for (const message of messages) {
items.push({ type: 'message', message });
for (const attempt of byMessageId.get(message.info.id) ?? []) {
items.push({ type: 'preparation', attempt });
}
}
for (const attempt of preparationAttempts) {
if (
!byMessageId.has(attempt.triggerMessageId) ||
!messages.some(message => message.info.id === attempt.triggerMessageId)
) {
items.push({ type: 'preparation', attempt });
}
}
return items;
}, [messages, preparationAttempts]);

const renderItem = useCallback(
({ item, index }: { item: StoredMessage; index: number }) => (
<MessageBubble
message={item}
isLastAssistantMessage={index === lastAssistantIndex}
isSessionStreaming={isStreaming}
getChildMessages={getChildMessages}
defaultReasoningExpanded={reasoningDefaultExpanded}
onOpenChildSession={handleOpenChildSession}
/>
),
({ item }: { item: TranscriptItem }) =>
item.type === 'preparation' ? (
<PreparationGroup attempt={item.attempt} />
) : (
<MessageBubble
message={item.message}
isLastAssistantMessage={item.message.info.id === lastAssistantMessageId}
isSessionStreaming={isStreaming}
getChildMessages={getChildMessages}
defaultReasoningExpanded={reasoningDefaultExpanded}
onOpenChildSession={handleOpenChildSession}
/>
),
[
lastAssistantIndex,
lastAssistantMessageId,
isStreaming,
getChildMessages,
reasoningDefaultExpanded,
Expand Down Expand Up @@ -606,8 +647,10 @@ export function SessionDetailContent({
return (
<FlatList
ref={flatListRef}
data={messages}
keyExtractor={item => item.info.id}
data={transcript}
keyExtractor={item =>
item.type === 'message' ? item.message.info.id : `preparation:${item.attempt.id}`
}
renderItem={renderItem}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
Expand Down
Loading