refactor(agent): unify timeline events with tool_call schema and add resume/refresh capabilities

This commit is contained in:
Mohamed Boudra
2025-11-15 11:58:41 +01:00
parent 8cf808b14d
commit c37e499562
23 changed files with 2636 additions and 454 deletions
+42 -1
View File
@@ -13,7 +13,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { MoreVertical, GitBranch, Folder } from "lucide-react-native";
import { MoreVertical, GitBranch, Folder, RotateCcw } from "lucide-react-native";
import { BackHeader } from "@/components/headers/back-header";
import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area";
@@ -34,6 +34,7 @@ export default function AgentScreen() {
pendingPermissions,
respondToPermission,
initializeAgent,
refreshAgent,
} = useSession();
const { registerFooterControls, unregisterFooterControls } = useFooterControls();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
@@ -192,6 +193,18 @@ export default function AgentScreen() {
}
}, [handleCloseMenu, id, router]);
const handleRefreshAgent = useCallback(() => {
if (!id) {
return;
}
if (!agent?.persistence) {
handleCloseMenu();
return;
}
handleCloseMenu();
refreshAgent({ agentId: id });
}, [agent?.persistence, handleCloseMenu, id, refreshAgent]);
if (!agent) {
return (
<View style={styles.container}>
@@ -268,6 +281,28 @@ export default function AgentScreen() {
<Folder size={20} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>Browse Files</Text>
</Pressable>
{agent.persistence && (
<Pressable
onPress={handleRefreshAgent}
style={[
styles.menuItem,
isInitializing ? styles.menuItemDisabled : null,
]}
disabled={isInitializing}
>
<RotateCcw size={20} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>
{isInitializing ? "Refreshing..." : "Refresh from disk"}
</Text>
{isInitializing && (
<ActivityIndicator
size="small"
color={theme.colors.primary}
style={styles.menuItemSpinner}
/>
)}
</Pressable>
)}
</View>
</View>
</Modal>
@@ -334,6 +369,12 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
menuItemDisabled: {
opacity: 0.6,
},
menuItemSpinner: {
marginLeft: "auto",
},
menuItemText: {
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
+270 -51
View File
@@ -1,7 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
Image,
FlatList,
Image as RNImage,
ListRenderItemInfo,
NativeScrollEvent,
NativeSyntheticEvent,
Pressable,
ScrollView,
Text,
@@ -10,7 +14,15 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useLocalSearchParams } from "expo-router";
import * as Clipboard from "expo-clipboard";
import { Copy, Check, ArrowLeft } from "lucide-react-native";
import {
Copy,
Check,
ArrowLeft,
File,
FileText,
Folder,
Image as ImageIcon,
} from "lucide-react-native";
import { BackHeader } from "@/components/headers/back-header";
import { useSession, type ExplorerEntry } from "@/contexts/session-context";
@@ -36,6 +48,8 @@ export default function FileExplorerScreen() {
const pendingFileParamRef = useRef<string | null>(null);
const [copiedPath, setCopiedPath] = useState<string | null>(null);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const listScrollRef = useRef<FlatList<ExplorerEntry> | null>(null);
const listScrollOffsetRef = useRef(0);
const normalizedPathParam = normalizePathParam(getFirstParam(pathParamRaw));
const normalizedFileParam = normalizeFileParam(getFirstParam(fileParamRaw));
@@ -47,9 +61,21 @@ export default function FileExplorerScreen() {
const agent = agentId ? agents.get(agentId) : undefined;
const explorerState = agentId ? fileExplorer.get(agentId) : undefined;
const currentPath = explorerState?.currentPath ?? ".";
const directory = explorerState?.directories.get(currentPath);
const pendingRequest = explorerState?.pendingRequest ?? null;
const isExplorerLoading = explorerState?.isLoading ?? false;
const isListingLoading = Boolean(
isExplorerLoading && pendingRequest?.mode === "list"
);
const pendingDirectoryPath =
isListingLoading && pendingRequest ? pendingRequest.path : null;
const activePath = pendingDirectoryPath ?? currentPath;
const directory = explorerState?.directories.get(activePath);
const entries = directory?.entries ?? [];
const isLoading = explorerState?.isLoading ?? false;
const showInitialListLoading = isListingLoading && entries.length === 0;
const showListLoadingBanner = isListingLoading && entries.length > 0;
const isPreviewLoading = Boolean(
isExplorerLoading && pendingRequest?.mode === "file"
);
const error = explorerState?.lastError ?? null;
const preview = selectedEntryPath
? explorerState?.files.get(selectedEntryPath)
@@ -58,21 +84,39 @@ export default function FileExplorerScreen() {
useEffect(() => {
setSelectedEntryPath(null);
}, [currentPath]);
}, [activePath]);
useEffect(() => {
if (shouldShowPreview) {
return;
}
const targetOffset = listScrollOffsetRef.current;
if (!listScrollRef.current) {
return;
}
listScrollRef.current.scrollToOffset({ offset: targetOffset, animated: false });
}, [shouldShowPreview]);
useEffect(() => {
listScrollOffsetRef.current = 0;
listScrollRef.current?.scrollToOffset({ offset: 0, animated: false });
}, [activePath]);
const parentPath = useMemo(() => {
if (currentPath === ".") {
if (activePath === ".") {
return null;
}
const segments = currentPath.split("/");
const segments = activePath.split("/");
segments.pop();
const nextPath = segments.join("/");
return nextPath.length === 0 ? "." : nextPath;
}, [currentPath]);
}, [activePath]);
useEffect(() => {
setCopiedPath(null);
}, [currentPath]);
}, [activePath]);
useEffect(() => {
if (!agentId || !initialTargetDirectory) {
@@ -163,6 +207,76 @@ export default function FileExplorerScreen() {
};
}, []);
const handleListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
listScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
},
[]
);
const renderEntry = useCallback(
({ item }: ListRenderItemInfo<ExplorerEntry>) => {
const displayKind = getEntryDisplayKind(item);
return (
<Pressable
style={[
styles.entryRow,
item.kind === "directory" ? styles.directoryRow : styles.fileRow,
]}
onPress={() => handleEntryPress(item)}
>
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{renderEntryIcon(displayKind, theme.colors)}
</View>
<View style={styles.entryTextContainer}>
<Text style={styles.entryName}>{item.name}</Text>
<Text style={styles.entryMeta}>
{item.kind.toUpperCase()} · {formatFileSize({ size: item.size })} ·{" "}
{formatModifiedTime({ value: item.modifiedAt })}
</Text>
</View>
</View>
<Pressable
onPress={(event) => {
event.stopPropagation();
handleCopyPath(item.path);
}}
hitSlop={8}
style={styles.copyButton}
>
{copiedPath === item.path ? (
<Check size={16} color={theme.colors.primary} />
) : (
<Copy size={16} color={theme.colors.foreground} />
)}
</Pressable>
</Pressable>
);
},
[copiedPath, handleCopyPath, handleEntryPress, theme.colors]
);
const listHeaderComponent = useMemo(() => {
if (!parentPath && !showListLoadingBanner) {
return null;
}
return (
<View style={styles.headerContainer}>
{parentPath && <UpRow label=".." onPress={handleNavigateUp} />}
{showListLoadingBanner && (
<View style={styles.loadingBanner}>
<ActivityIndicator size="small" />
<Text style={styles.loadingBannerText}>
Loading {formatDirectoryLabel(activePath)}...
</Text>
</View>
)}
</View>
);
}, [activePath, handleNavigateUp, parentPath, showListLoadingBanner]);
if (!agent) {
return (
<View style={styles.container}>
@@ -176,14 +290,14 @@ export default function FileExplorerScreen() {
return (
<View style={styles.container}>
<BackHeader title={selectedEntryPath ?? (currentPath || ".")} />
<BackHeader title={selectedEntryPath ?? (activePath || ".")} />
<View style={styles.content}>
{shouldShowPreview ? (
<View style={styles.previewWrapper}>
<UpRow label="Back to directory" onPress={() => setSelectedEntryPath(null)} />
<View style={styles.previewSection}>
{isLoading && !preview ? (
{isPreviewLoading && !preview ? (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file...</Text>
@@ -204,7 +318,7 @@ export default function FileExplorerScreen() {
</ScrollView>
) : preview.kind === "image" && preview.content ? (
<View style={styles.imagePreviewContainer}>
<Image
<RNImage
source={{
uri: `data:${preview.mimeType ?? "image/png"};base64,${
preview.content
@@ -230,52 +344,30 @@ export default function FileExplorerScreen() {
<View style={styles.centerState}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : isLoading && entries.length === 0 ? (
) : showInitialListLoading ? (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading...</Text>
<Text style={styles.loadingText}>Loading directory...</Text>
</View>
) : entries.length === 0 ? (
<View style={styles.centerState}>
<Text style={styles.emptyText}>Directory is empty</Text>
</View>
) : (
<ScrollView contentContainerStyle={styles.entriesContent}>
{parentPath && <UpRow label=".." onPress={handleNavigateUp} />}
{entries.map((entry) => (
<Pressable
key={entry.path}
style={[
styles.entryRow,
entry.kind === "directory"
? styles.directoryRow
: styles.fileRow,
]}
onPress={() => handleEntryPress(entry)}
>
<View style={styles.entryTextContainer}>
<Text style={styles.entryName}>{entry.name}</Text>
<Text style={styles.entryMeta}>
{entry.kind.toUpperCase()} · {formatFileSize({ size: entry.size })} · {formatModifiedTime({ value: entry.modifiedAt })}
</Text>
</View>
<Pressable
onPress={(event) => {
event.stopPropagation();
handleCopyPath(entry.path);
}}
hitSlop={8}
style={styles.copyButton}
>
{copiedPath === entry.path ? (
<Check size={16} color={theme.colors.primary} />
) : (
<Copy size={16} color={theme.colors.foreground} />
)}
</Pressable>
</Pressable>
))}
</ScrollView>
<FlatList
ref={listScrollRef}
data={entries}
renderItem={renderEntry}
keyExtractor={(item) => item.path}
contentContainerStyle={styles.entriesContent}
onScroll={handleListScroll}
scrollEventThrottle={16}
ListHeaderComponent={listHeaderComponent}
extraData={copiedPath}
initialNumToRender={20}
maxToRenderPerBatch={30}
windowSize={10}
/>
)}
</View>
)}
@@ -284,6 +376,10 @@ export default function FileExplorerScreen() {
);
}
function formatDirectoryLabel(path: string): string {
return path === "." ? "workspace root" : path;
}
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
@@ -351,6 +447,104 @@ function UpRow({ label, onPress }: { label: string; onPress: () => void }) {
);
}
type EntryDisplayKind = "directory" | "image" | "text" | "other";
const IMAGE_EXTENSIONS = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"svg",
"webp",
"ico",
]);
const TEXT_EXTENSIONS = new Set([
"txt",
"md",
"markdown",
"ts",
"tsx",
"js",
"jsx",
"json",
"yml",
"yaml",
"toml",
"py",
"rb",
"go",
"rs",
"java",
"kt",
"c",
"cpp",
"cc",
"h",
"hpp",
"cs",
"swift",
"php",
"html",
"css",
"scss",
"less",
"xml",
"sh",
"bash",
"zsh",
"ini",
"cfg",
"conf",
]);
function renderEntryIcon(
kind: EntryDisplayKind,
colors: { foreground: string; primary: string }
) {
const color = colors.foreground;
switch (kind) {
case "directory":
return <Folder size={18} color={colors.primary} />;
case "image":
return <ImageIcon size={18} color={color} />;
case "text":
return <FileText size={18} color={color} />;
default:
return <File size={18} color={color} />;
}
}
function getEntryDisplayKind(entry: ExplorerEntry): EntryDisplayKind {
if (entry.kind === "directory") {
return "directory";
}
const extension = getExtension(entry.name);
if (extension === null) {
return "other";
}
if (IMAGE_EXTENSIONS.has(extension)) {
return "image";
}
if (TEXT_EXTENSIONS.has(extension)) {
return "text";
}
return "other";
}
function getExtension(name: string): string | null {
const index = name.lastIndexOf(".");
if (index === -1 || index === name.length - 1) {
return null;
}
return name.slice(index + 1).toLowerCase();
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -369,6 +563,21 @@ const styles = StyleSheet.create((theme) => ({
entriesContent: {
paddingBottom: theme.spacing[4],
},
headerContainer: {
gap: theme.spacing[2],
paddingBottom: theme.spacing[2],
},
loadingBanner: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
loadingBannerText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
previewWrapper: {
flex: 1,
gap: theme.spacing[2],
@@ -421,9 +630,19 @@ const styles = StyleSheet.create((theme) => ({
selectedRow: {
borderColor: theme.colors.primary,
},
entryInfo: {
flex: 1,
flexDirection: "row",
alignItems: "center",
columnGap: theme.spacing[2],
marginRight: theme.spacing[3],
},
entryIcon: {
width: 28,
alignItems: "center",
},
entryTextContainer: {
flex: 1,
marginRight: theme.spacing[3],
},
entryName: {
color: theme.colors.foreground,
@@ -15,6 +15,7 @@ import Animated, {
} from "react-native-reanimated";
import type { Agent } from "@/contexts/session-context";
import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status";
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
interface AgentSidebarProps {
isOpen: boolean;
@@ -179,6 +180,7 @@ export function AgentSidebar({
const isActive = agent.id === activeAgentId;
const statusColor = getAgentStatusColor(agent.status);
const statusLabel = getAgentStatusLabel(agent.status);
const providerLabel = getAgentProviderDefinition(agent.provider).label;
return (
<Pressable
@@ -209,6 +211,18 @@ export function AgentSidebar({
</Text>
<View style={styles.statusBadge}>
<View style={[styles.providerBadge, { backgroundColor: theme.colors.muted }]}>
<Text
style={[
styles.providerText,
{ color: theme.colors.mutedForeground },
]}
numberOfLines={1}
>
{providerLabel}
</Text>
</View>
<View
style={[styles.statusDot, { backgroundColor: statusColor }]}
/>
@@ -309,6 +323,15 @@ const styles = StyleSheet.create({
alignItems: "center",
gap: 4,
},
providerBadge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
},
providerText: {
fontSize: 11,
fontWeight: "600",
},
statusDot: {
width: 6,
height: 6,
@@ -235,9 +235,11 @@ export function AgentStreamView({
if (payload.source === "agent") {
const data = payload.data;
const toolLabel = data.displayName ?? `${data.server}/${data.tool}`;
return (
<ToolCall
toolName={`${data.server}/${data.tool}`}
toolName={toolLabel}
kind={data.kind}
args={data.raw}
status={data.status as "executing" | "completed" | "failed"}
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
@@ -5,12 +5,14 @@ import {
Text,
Pressable,
ScrollView,
FlatList,
ActivityIndicator,
InteractionManager,
TextInput,
Modal,
useWindowDimensions,
type LayoutChangeEvent,
type ListRenderItem,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
@@ -32,7 +34,8 @@ import {
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
} from "@server/server/agent/provider-manifest";
import type { AgentProvider, AgentMode, AgentSessionConfig } from "@server/server/agent/agent-sdk-types";
import type { AgentProvider, AgentMode, AgentSessionConfig, AgentPersistenceHandle } from "@server/server/agent/agent-sdk-types";
import type { WSInboundMessage } from "@server/server/messages";
interface CreateAgentModalProps {
isVisible: boolean;
@@ -48,6 +51,40 @@ const fallbackDefinition = providerDefinitions[0];
const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? "";
const BACKDROP_OPACITY = 0.55;
const RESUME_PAGE_SIZE = 20;
type ResumeCandidate = {
provider: AgentProvider;
sessionId: string;
cwd: string;
title: string;
lastActivityAt: Date;
persistence: AgentPersistenceHandle;
};
type ResumeTab = "new" | "resume";
type ProviderFilter = "all" | AgentProvider;
function formatRelativeTime(date: Date): string {
const now = Date.now();
const diffMs = now - date.getTime();
if (!Number.isFinite(diffMs)) {
return "unknown";
}
const minutes = Math.max(0, Math.floor(diffMs / 60000));
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export function CreateAgentModal({
isVisible,
@@ -61,7 +98,7 @@ export function CreateAgentModal({
const isCompactLayout = screenWidth < 720;
const { recentPaths, addRecentPath } = useRecentPaths();
const { ws, createAgent } = useSession();
const { ws, createAgent, resumeAgent } = useSession();
const router = useRouter();
const [isMounted, setIsMounted] = useState(isVisible);
@@ -73,12 +110,54 @@ export function CreateAgentModal({
const [worktreeName, setWorktreeName] = useState("");
const [errorMessage, setErrorMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<ResumeTab>("new");
const [resumeProviderFilter, setResumeProviderFilter] = useState<ProviderFilter>("all");
const [resumeSearchQuery, setResumeSearchQuery] = useState("");
const [resumeCandidates, setResumeCandidates] = useState<ResumeCandidate[]>([]);
const [isResumeLoading, setIsResumeLoading] = useState(false);
const [resumeError, setResumeError] = useState<string | null>(null);
const pendingRequestIdRef = useRef<string | null>(null);
const pendingNavigationAgentIdRef = useRef<string | null>(null);
const tabOptions = useMemo(
() => [
{ id: "new" as ResumeTab, label: "New Agent" },
{ id: "resume" as ResumeTab, label: "Resume Agent" },
],
[]
);
const providerFilterOptions = useMemo(
() => [
{ id: "all" as ProviderFilter, label: "All" },
...providerDefinitions.map((definition) => ({
id: definition.id as ProviderFilter,
label: definition.label,
})),
],
[]
);
const getProviderLabel = useCallback(
(provider: AgentProvider) => providerDefinitionMap.get(provider)?.label ?? provider,
[]
);
const agentDefinition = providerDefinitionMap.get(selectedProvider);
const modeOptions = agentDefinition?.modes ?? [];
const filteredResumeCandidates = useMemo(() => {
const providerFilter = resumeProviderFilter;
const query = resumeSearchQuery.trim().toLowerCase();
return resumeCandidates
.filter((candidate) => providerFilter === "all" || candidate.provider === providerFilter)
.filter((candidate) => {
if (query.length === 0) {
return true;
}
const titleText = candidate.title.toLowerCase();
const cwdText = candidate.cwd.toLowerCase();
return titleText.includes(query) || cwdText.includes(query);
})
.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
}, [resumeCandidates, resumeProviderFilter, resumeSearchQuery]);
useEffect(() => {
if (!agentDefinition) {
@@ -108,7 +187,7 @@ export function CreateAgentModal({
setSelectedMode(DEFAULT_MODE_FOR_DEFAULT_PROVIDER);
setErrorMessage("");
setIsLoading(false);
setPendingRequestId(null);
pendingRequestIdRef.current = null;
}, []);
const navigateToAgentIfNeeded = useCallback(() => {
@@ -130,6 +209,29 @@ export function CreateAgentModal({
navigateToAgentIfNeeded();
}, [navigateToAgentIfNeeded, resetFormState]);
const requestResumeCandidates = useCallback(
(provider?: AgentProvider) => {
setIsResumeLoading(true);
setResumeError(null);
const msg: WSInboundMessage = {
type: "session",
message: {
type: "list_persisted_agents_request",
...(provider ? { provider } : {}),
limit: RESUME_PAGE_SIZE,
},
};
try {
ws.send(msg);
} catch (error) {
console.error("[CreateAgentModal] Failed to request persisted agents:", error);
setIsResumeLoading(false);
setResumeError("Unable to load saved agents. Please try again.");
}
},
[ws]
);
useEffect(() => {
if (!isVisible) {
console.log("[CreateAgentModal] visibility effect skipped (isVisible is false)", {
@@ -284,8 +386,8 @@ export function CreateAgentModal({
const requestId = generateMessageId();
pendingRequestIdRef.current = requestId;
setIsLoading(true);
setPendingRequestId(requestId);
setErrorMessage("");
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
@@ -306,7 +408,7 @@ export function CreateAgentModal({
console.error("[CreateAgentModal] Failed to create agent:", error);
setErrorMessage("Failed to create agent. Please try again.");
setIsLoading(false);
setPendingRequestId(null);
pendingRequestIdRef.current = null;
}
}, [
workingDir,
@@ -319,26 +421,96 @@ export function CreateAgentModal({
addRecentPath,
createAgent,
]);
const handleResumeCandidatePress = useCallback(
(candidate: ResumeCandidate) => {
if (isLoading) {
return;
}
setErrorMessage("");
const requestId = generateMessageId();
pendingRequestIdRef.current = requestId;
setIsLoading(true);
resumeAgent({
handle: candidate.persistence,
requestId,
});
},
[isLoading, resumeAgent]
);
const renderResumeItem = useCallback<ListRenderItem<ResumeCandidate>>(
({ item }) => (
<Pressable
onPress={() => handleResumeCandidatePress(item)}
disabled={isLoading}
style={styles.resumeItem}
>
<View style={styles.resumeItemHeader}>
<Text style={styles.resumeItemTitle} numberOfLines={1}>
{item.title}
</Text>
<Text style={styles.resumeItemTimestamp}>{formatRelativeTime(item.lastActivityAt)}</Text>
</View>
<Text style={styles.resumeItemPath} numberOfLines={1}>
{item.cwd}
</Text>
<View style={styles.resumeItemMetaRow}>
<View style={styles.resumeProviderBadge}>
<Text style={styles.resumeProviderBadgeText}>{getProviderLabel(item.provider)}</Text>
</View>
<Text style={styles.resumeItemHint}>Tap to resume</Text>
</View>
</Pressable>
),
[getProviderLabel, handleResumeCandidatePress, isLoading]
);
useEffect(() => {
if (!pendingRequestId) {
return;
}
const unsubscribe = ws.on("list_persisted_agents_response", (message) => {
if (message.type !== "list_persisted_agents_response") {
return;
}
const mapped = message.payload.items.map((item) => ({
provider: item.provider,
sessionId: item.sessionId,
cwd: item.cwd,
title: item.title ?? `Session ${item.sessionId.slice(0, 8)}`,
lastActivityAt: new Date(item.lastActivityAt),
persistence: item.persistence,
})) as ResumeCandidate[];
setResumeCandidates(mapped);
setIsResumeLoading(false);
});
return () => {
unsubscribe();
};
}, [ws]);
useEffect(() => {
const unsubscribe = ws.on("status", (message) => {
if (message.type !== "status") {
return;
}
const payload = message.payload as { status: string; agentId?: string; requestId?: string };
if (payload.status !== "agent_created") {
if (
(payload.status !== "agent_created" && payload.status !== "agent_resumed") ||
!payload.agentId
) {
return;
}
if (payload.requestId !== pendingRequestId || !payload.agentId) {
const expectedRequestId = pendingRequestIdRef.current;
if (!expectedRequestId || payload.requestId !== expectedRequestId) {
return;
}
console.log("[CreateAgentModal] Agent created:", payload.agentId);
pendingRequestIdRef.current = null;
setIsLoading(false);
setPendingRequestId(null);
pendingNavigationAgentIdRef.current = payload.agentId;
handleClose();
});
@@ -346,7 +518,20 @@ export function CreateAgentModal({
return () => {
unsubscribe();
};
}, [pendingRequestId, ws, handleClose]);
}, [ws, handleClose]);
useEffect(() => {
if (!isVisible || activeTab !== "resume") {
return;
}
const provider = resumeProviderFilter === "all" ? undefined : resumeProviderFilter;
requestResumeCandidates(provider);
}, [activeTab, isVisible, requestResumeCandidates, resumeProviderFilter]);
const refreshResumeList = useCallback(() => {
const provider = resumeProviderFilter === "all" ? undefined : resumeProviderFilter;
requestResumeCandidates(provider);
}, [requestResumeCandidates, resumeProviderFilter]);
const shouldRender = isVisible || isMounted;
@@ -395,93 +580,194 @@ export function CreateAgentModal({
paddingLeft={horizontalPaddingLeft}
paddingRight={horizontalPaddingRight}
onClose={handleClose}
title={activeTab === "resume" ? "Resume Agent" : "Create New Agent"}
/>
<ScrollView
style={styles.scroll}
contentContainerStyle={[
styles.scrollContent,
<View
style={[
styles.tabSelector,
{
paddingBottom: insets.bottom + defaultTheme.spacing[16],
paddingLeft: horizontalPaddingLeft,
paddingRight: horizontalPaddingRight,
},
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<WorkingDirectorySection
errorMessage={errorMessage}
isLoading={isLoading}
recentPaths={recentPaths}
workingDir={workingDir}
onChangeWorkingDir={(value) => {
setWorkingDir(value);
setErrorMessage("");
}}
/>
{tabOptions.map((tab) => {
const isActive = activeTab === tab.id;
return (
<Pressable
key={tab.id}
onPress={() => setActiveTab(tab.id)}
style={[styles.tabButton, isActive && styles.tabButtonActive]}
>
<Text style={[styles.tabButtonText, isActive && styles.tabButtonTextActive]}>{tab.label}</Text>
</Pressable>
);
})}
</View>
{activeTab === "new" ? (
<>
<ScrollView
style={styles.scroll}
contentContainerStyle={[
styles.scrollContent,
{
paddingBottom: insets.bottom + defaultTheme.spacing[16],
paddingLeft: horizontalPaddingLeft,
paddingRight: horizontalPaddingRight,
},
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<WorkingDirectorySection
errorMessage={errorMessage}
isLoading={isLoading}
recentPaths={recentPaths}
workingDir={workingDir}
onChangeWorkingDir={(value) => {
setWorkingDir(value);
setErrorMessage("");
}}
/>
<View
style={[
styles.selectorRow,
isCompactLayout && styles.selectorRowStacked,
]}
>
<AssistantSelector
providerDefinitions={providerDefinitions}
disabled={isLoading}
isStacked={isCompactLayout}
selectedProvider={selectedProvider}
onSelect={setSelectedProvider}
/>
<ModeSelector
disabled={isLoading}
isStacked={isCompactLayout}
modeOptions={modeOptions}
selectedMode={selectedMode}
onSelect={setSelectedMode}
/>
</View>
<WorktreeSection
isLoading={isLoading}
value={worktreeName}
onChange={(text) => {
const slugified = slugifyWorktreeName(text);
setWorktreeName(slugified);
setErrorMessage("");
}}
/>
</ScrollView>
<Animated.View
style={[
styles.footer,
{
paddingBottom: insets.bottom + defaultTheme.spacing[4],
paddingLeft: horizontalPaddingLeft,
paddingRight: horizontalPaddingRight,
},
footerAnimatedStyle,
]}
>
<Pressable
style={[
styles.createButton,
(workingDirIsEmpty || isLoading) && styles.createButtonDisabled,
]}
onPress={handleCreate}
disabled={workingDirIsEmpty || isLoading}
>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator color={defaultTheme.colors.palette.white} />
<Text style={styles.createButtonText}>Creating...</Text>
</View>
) : (
<Text style={styles.createButtonText}>Create Agent</Text>
)}
</Pressable>
</Animated.View>
</>
) : (
<View
style={[
styles.selectorRow,
isCompactLayout && styles.selectorRowStacked,
styles.resumeContainer,
{
paddingLeft: horizontalPaddingLeft,
paddingRight: horizontalPaddingRight,
paddingBottom: insets.bottom + defaultTheme.spacing[4],
},
]}
>
<AssistantSelector
providerDefinitions={providerDefinitions}
disabled={isLoading}
isStacked={isCompactLayout}
selectedProvider={selectedProvider}
onSelect={setSelectedProvider}
/>
<ModeSelector
disabled={isLoading}
isStacked={isCompactLayout}
modeOptions={modeOptions}
selectedMode={selectedMode}
onSelect={setSelectedMode}
/>
</View>
<WorktreeSection
isLoading={isLoading}
value={worktreeName}
onChange={(text) => {
const slugified = slugifyWorktreeName(text);
setWorktreeName(slugified);
setErrorMessage("");
}}
/>
</ScrollView>
<Animated.View
style={[
styles.footer,
{
paddingBottom: insets.bottom + defaultTheme.spacing[4],
paddingLeft: horizontalPaddingLeft,
paddingRight: horizontalPaddingRight,
},
footerAnimatedStyle,
]}
>
<Pressable
style={[
styles.createButton,
(workingDirIsEmpty || isLoading) && styles.createButtonDisabled,
]}
onPress={handleCreate}
disabled={workingDirIsEmpty || isLoading}
>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator color={defaultTheme.colors.palette.white} />
<Text style={styles.createButtonText}>Creating...</Text>
<View style={styles.resumeFilters}>
<View style={styles.providerFilterRow}>
{providerFilterOptions.map((option) => {
const isActive = resumeProviderFilter === option.id;
return (
<Pressable
key={option.id}
onPress={() => setResumeProviderFilter(option.id)}
style={[styles.providerFilterButton, isActive && styles.providerFilterButtonActive]}
>
<Text
style={[
styles.providerFilterText,
isActive && styles.providerFilterTextActive,
]}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
<View style={styles.resumeSearchRow}>
<TextInput
style={styles.resumeSearchInput}
placeholder="Search by title or path"
placeholderTextColor={defaultTheme.colors.mutedForeground}
value={resumeSearchQuery}
onChangeText={setResumeSearchQuery}
/>
<Pressable style={styles.refreshButton} onPress={refreshResumeList} disabled={isResumeLoading}>
<Text style={styles.refreshButtonText}>Refresh</Text>
</Pressable>
</View>
</View>
{resumeError ? <Text style={styles.resumeErrorText}>{resumeError}</Text> : null}
{isResumeLoading ? (
<View style={styles.resumeLoading}>
<ActivityIndicator color={defaultTheme.colors.mutedForeground} />
<Text style={styles.resumeLoadingText}>Loading saved agents...</Text>
</View>
) : filteredResumeCandidates.length === 0 ? (
<View style={styles.resumeEmptyState}>
<Text style={styles.resumeEmptyTitle}>No agents found</Text>
<Text style={styles.resumeEmptySubtitle}>
We'll load the latest Claude and Codex sessions from your local history.
</Text>
<Pressable style={styles.refreshButtonAlt} onPress={refreshResumeList}>
<Text style={styles.refreshButtonAltText}>Try Again</Text>
</Pressable>
</View>
) : (
<Text style={styles.createButtonText}>Create Agent</Text>
<FlatList
data={filteredResumeCandidates}
renderItem={renderResumeItem}
keyExtractor={(item) => `${item.provider}:${item.sessionId}`}
ItemSeparatorComponent={() => <View style={styles.resumeItemSeparator} />}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.resumeListContent}
/>
)}
</Pressable>
</Animated.View>
</View>
)}
</View>
</Animated.View>
</View>
@@ -494,12 +780,13 @@ interface ModalHeaderProps {
paddingLeft: number;
paddingRight: number;
onClose: () => void;
title: string;
}
function ModalHeader({ paddingTop, paddingLeft, paddingRight, onClose }: ModalHeaderProps): ReactElement {
function ModalHeader({ paddingTop, paddingLeft, paddingRight, onClose, title }: ModalHeaderProps): ReactElement {
return (
<View style={[styles.header, { paddingTop, paddingLeft, paddingRight }]}>
<Text style={styles.headerTitle}>Create New Agent</Text>
<Text style={styles.headerTitle}>{title}</Text>
<Pressable onPress={onClose} style={styles.closeButton} hitSlop={8}>
<X size={20} color={defaultTheme.colors.mutedForeground} />
</Pressable>
@@ -742,6 +1029,35 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
tabSelector: {
flexDirection: "row",
gap: theme.spacing[2],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[4],
},
tabButton: {
flex: 1,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
paddingVertical: theme.spacing[3],
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.background,
},
tabButtonActive: {
backgroundColor: theme.colors.muted,
borderColor: theme.colors.palette.blue[500],
},
tabButtonText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
tabButtonTextActive: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
},
scroll: {
flex: 1,
},
@@ -894,4 +1210,161 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
resumeContainer: {
flex: 1,
gap: theme.spacing[4],
},
resumeFilters: {
gap: theme.spacing[3],
},
providerFilterRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
},
providerFilterButton: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.full,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.background,
},
providerFilterButtonActive: {
borderColor: theme.colors.palette.blue[500],
backgroundColor: theme.colors.muted,
},
providerFilterText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
providerFilterTextActive: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
},
resumeSearchRow: {
flexDirection: "row",
gap: theme.spacing[3],
alignItems: "center",
},
resumeSearchInput: {
flex: 1,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.background,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
},
refreshButton: {
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
refreshButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
resumeErrorText: {
color: theme.colors.palette.red[500],
fontSize: theme.fontSize.sm,
},
resumeLoading: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
},
resumeLoadingText: {
color: theme.colors.mutedForeground,
},
resumeEmptyState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[6],
textAlign: "center",
},
resumeEmptyTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
textAlign: "center",
},
resumeEmptySubtitle: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
refreshButtonAlt: {
marginTop: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.palette.blue[500],
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[3],
},
refreshButtonAltText: {
color: theme.colors.palette.white,
fontWeight: theme.fontWeight.semibold,
},
resumeListContent: {
paddingBottom: theme.spacing[8],
gap: theme.spacing[2],
},
resumeItem: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[4],
backgroundColor: theme.colors.background,
gap: theme.spacing[2],
},
resumeItemHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: theme.spacing[2],
},
resumeItemTitle: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
flex: 1,
},
resumeItemTimestamp: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
resumeItemPath: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
resumeItemMetaRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
resumeProviderBadge: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.muted,
},
resumeProviderBadgeText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
resumeItemHint: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
},
resumeItemSeparator: {
height: theme.spacing[2],
},
}));
+2
View File
@@ -17,6 +17,7 @@ import {
Eye,
SquareTerminal,
Brain,
Search,
} from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { baseColors, theme } from "@/styles/theme";
@@ -778,6 +779,7 @@ const toolKindIcons: Record<string, any> = {
edit: Pencil,
read: Eye,
execute: SquareTerminal,
search: Search,
// Add more mappings as needed
};
@@ -212,10 +212,10 @@ export function ToolCallBottomSheet({
if (payload.source === "agent") {
const data = payload.data;
return {
toolName: `${data.server}/${data.tool}`,
toolName: data.displayName ?? `${data.server}/${data.tool}`,
args: data.raw,
result: undefined,
error: undefined,
result: data.result,
error: data.error,
};
}
@@ -210,9 +210,11 @@ interface SessionContextValue {
// Helpers
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
refreshAgent: (params: { agentId: string; requestId?: string }) => void;
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
sendAgentAudio: (agentId: string, audioBlob: Blob, requestId?: string) => Promise<void>;
createAgent: (options: { config: AgentSessionConfig; worktreeName?: string; requestId?: string }) => void;
resumeAgent: (options: { handle: AgentPersistenceHandle; overrides?: Partial<AgentSessionConfig>; requestId?: string }) => void;
setAgentMode: (agentId: string, modeId: string) => void;
respondToPermission: (agentId: string, requestId: string, response: AgentPermissionResponse) => void;
}
@@ -729,6 +731,30 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg);
}, [ws]);
const refreshAgent = useCallback(({ agentId, requestId }: { agentId: string; requestId?: string }) => {
setInitializingAgents((prev) => {
const next = new Map(prev);
next.set(agentId, true);
return next;
});
setAgentStreamState((prev) => {
const next = new Map(prev);
next.set(agentId, []);
return next;
});
const msg: WSInboundMessage = {
type: "session",
message: {
type: "refresh_agent_request",
agentId,
requestId,
},
};
ws.send(msg);
}, [ws]);
const sendAgentMessage = useCallback(async (agentId: string, message: string, imageUris?: string[]) => {
// Generate unique message ID for deduplication
const messageId = generateMessageId();
@@ -832,6 +858,19 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg);
}, [ws]);
const resumeAgent = useCallback(({ handle, overrides, requestId }: { handle: AgentPersistenceHandle; overrides?: Partial<AgentSessionConfig>; requestId?: string }) => {
const msg: WSInboundMessage = {
type: "session",
message: {
type: "resume_agent_request",
handle,
...(overrides ? { overrides } : {}),
...(requestId ? { requestId } : {}),
},
};
ws.send(msg);
}, [ws]);
const setAgentMode = useCallback((agentId: string, modeId: string) => {
const msg: WSInboundMessage = {
type: "session",
@@ -941,9 +980,11 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
requestDirectoryListing,
requestFilePreview,
initializeAgent,
refreshAgent,
sendAgentMessage,
sendAgentAudio,
createAgent,
resumeAgent,
setAgentMode,
respondToPermission,
};
+206 -126
View File
@@ -68,6 +68,11 @@ export interface AgentToolCallData {
tool: string;
status: string;
raw?: unknown;
callId?: string;
displayName?: string;
kind?: string;
result?: unknown;
error?: unknown;
}
export type ToolCallPayload =
@@ -81,6 +86,10 @@ export interface ToolCallItem {
payload: ToolCallPayload;
}
type AgentToolCallItem = ToolCallItem & {
payload: { source: "agent"; data: AgentToolCallData };
};
type ActivityLogType = "system" | "info" | "success" | "error";
export interface ActivityLogItem {
@@ -92,12 +101,54 @@ export interface ActivityLogItem {
metadata?: Record<string, unknown>;
}
type FileChangeEntry = { path: string; kind: string };
type TodoEntry = { text: string; completed: boolean };
function normalizeChunk(text: string): { chunk: string; hasContent: boolean } {
if (!text) {
return { chunk: "", hasContent: false };
}
const chunk = text.replace(/\r/g, "");
if (!chunk) {
return { chunk: "", hasContent: false };
}
return { chunk, hasContent: /\S/.test(chunk) };
}
function appendUserMessage(
state: StreamItem[],
text: string,
timestamp: Date,
messageId?: string
): StreamItem[] {
const { chunk, hasContent } = normalizeChunk(text);
if (!hasContent) {
return state;
}
const entryId = messageId ?? createTimelineId("user", chunk.trim() || chunk, timestamp);
const existingIndex = state.findIndex(
(entry) => entry.kind === "user_message" && entry.id === entryId
);
const nextItem: UserMessageItem = {
kind: "user_message",
id: entryId,
text: chunk,
timestamp,
};
if (existingIndex >= 0) {
const next = [...state];
next[existingIndex] = nextItem;
return next;
}
return [...state, nextItem];
}
function appendAssistantMessage(state: StreamItem[], text: string, timestamp: Date): StreamItem[] {
const trimmed = text.trim();
if (!trimmed) {
const { chunk, hasContent } = normalizeChunk(text);
if (!chunk) {
return state;
}
@@ -105,24 +156,29 @@ function appendAssistantMessage(state: StreamItem[], text: string, timestamp: Da
if (last && last.kind === "assistant_message") {
const updated: AssistantMessageItem = {
...last,
text: `${last.text}${trimmed}`,
text: `${last.text}${chunk}`,
timestamp,
};
return [...state.slice(0, -1), updated];
}
if (!hasContent) {
return state;
}
const idSeed = chunk.trim() || chunk;
const item: AssistantMessageItem = {
kind: "assistant_message",
id: createTimelineId("assistant", trimmed, timestamp),
text: trimmed,
id: createTimelineId("assistant", idSeed, timestamp),
text: chunk,
timestamp,
};
return [...state, item];
}
function appendThought(state: StreamItem[], text: string, timestamp: Date): StreamItem[] {
const trimmed = text.trim();
if (!trimmed) {
const { chunk, hasContent } = normalizeChunk(text);
if (!chunk) {
return state;
}
@@ -130,16 +186,21 @@ function appendThought(state: StreamItem[], text: string, timestamp: Date): Stre
if (last && last.kind === "thought") {
const updated: ThoughtItem = {
...last,
text: `${last.text}${trimmed}`,
text: `${last.text}${chunk}`,
timestamp,
};
return [...state.slice(0, -1), updated];
}
if (!hasContent) {
return state;
}
const idSeed = chunk.trim() || chunk;
const item: ThoughtItem = {
kind: "thought",
id: createTimelineId("thought", trimmed, timestamp),
text: trimmed,
id: createTimelineId("thought", idSeed, timestamp),
text: chunk,
timestamp,
};
return [...state, item];
@@ -150,19 +211,56 @@ function appendAgentToolCall(
data: AgentToolCallData,
timestamp: Date
): StreamItem[] {
const status = data.status;
const id = createTimelineId(
"tool",
`${data.provider}:${data.server}:${data.tool}:${status}`,
timestamp
);
const normalizedStatus = normalizeToolCallStatus(data.status);
const callId = data.callId ?? extractToolCallId(data.raw);
const normalizedStatus: "executing" | "completed" | "failed" =
status === "completed"
? "completed"
: status === "failed"
? "failed"
: "executing";
const payloadData: AgentToolCallData = {
...data,
status: normalizedStatus,
callId: callId ?? data.callId,
};
if (callId) {
const existingIndex = state.findIndex(
(entry) =>
entry.kind === "tool_call" &&
entry.payload.source === "agent" &&
entry.payload.data.callId === callId
);
if (existingIndex >= 0) {
const next = [...state];
const existing = next[existingIndex] as AgentToolCallItem;
const mergedRaw =
existing.payload.data.raw !== undefined && existing.payload.data.raw !== null
? existing.payload.data.raw
: payloadData.raw;
next[existingIndex] = {
...existing,
timestamp,
payload: {
source: "agent",
data: {
...existing.payload.data,
...payloadData,
raw: mergedRaw,
displayName: payloadData.displayName ?? existing.payload.data.displayName,
kind: payloadData.kind ?? existing.payload.data.kind,
callId,
},
},
};
return next;
}
}
const id = callId
? `agent_tool_${callId}`
: createTimelineId(
"tool",
`${data.provider}:${data.server}:${data.tool}`,
timestamp
);
const item: ToolCallItem = {
kind: "tool_call",
@@ -170,21 +268,82 @@ function appendAgentToolCall(
timestamp,
payload: {
source: "agent",
data: { ...data, status: normalizedStatus },
data: payloadData,
},
};
// Replace existing entry with same ID if present
const index = state.findIndex((entry) => entry.id === id);
if (index >= 0) {
const next = [...state];
next[index] = item;
return next;
}
return [...state, item];
}
function isPermissionToolCall(raw: unknown): boolean {
if (!raw || typeof raw !== "object") {
return false;
}
const candidate = raw as { server?: string; kind?: string };
return candidate.server === "permission" || candidate.kind === "permission";
}
function normalizeToolCallStatus(status?: string): "executing" | "completed" | "failed" {
if (!status) {
return "executing";
}
const normalized = status.toLowerCase();
if (/fail|error|deny|reject|cancel/.test(normalized)) {
return "failed";
}
if (/complete|success|granted|applied|done|resolved/.test(normalized)) {
return "completed";
}
return "executing";
}
const TOOL_CALL_ID_KEYS = [
"toolCallId",
"tool_call_id",
"callId",
"call_id",
"tool_use_id",
"toolUseId",
];
function extractToolCallId(raw: unknown, depth = 0): string | null {
if (!raw || depth > 4) {
return null;
}
if (typeof raw === "string" || typeof raw === "number") {
return null;
}
if (Array.isArray(raw)) {
for (const entry of raw) {
const nested = extractToolCallId(entry, depth + 1);
if (nested) {
return nested;
}
}
return null;
}
if (typeof raw === "object") {
const record = raw as Record<string, unknown>;
for (const key of TOOL_CALL_ID_KEYS) {
const value = record[key];
if (typeof value === "string" && value.length > 0) {
return value;
}
}
const idValue = record.id;
if (typeof idValue === "string" && /tool|call/i.test(idValue)) {
return idValue;
}
for (const value of Object.values(record)) {
const nested = extractToolCallId(value, depth + 1);
if (nested) {
return nested;
}
}
}
return null;
}
function appendActivityLog(state: StreamItem[], entry: ActivityLogItem): StreamItem[] {
const index = state.findIndex((existing) => existing.id === entry.id);
if (index >= 0) {
@@ -195,64 +354,6 @@ function appendActivityLog(state: StreamItem[], entry: ActivityLogItem): StreamI
return [...state, entry];
}
function toHumanLabel(value?: string): string {
if (!value) {
return "";
}
return value
.replace(/[_-]+/g, " ")
.split(" ")
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
function describeCommandStatus(status?: string): ActivityLogType {
if (!status) {
return "info";
}
const normalized = status.toLowerCase();
if (
normalized.includes("fail") ||
normalized.includes("error") ||
normalized.includes("deny") ||
normalized.includes("reject")
) {
return "error";
}
if (
normalized.includes("success") ||
normalized.includes("complete") ||
normalized.includes("granted") ||
normalized.includes("applied")
) {
return "success";
}
return "info";
}
function formatCommandMessage(command: string, status?: string): string {
const label = status ? toHumanLabel(status) : null;
const header = label ? `Command (${label})` : "Command";
return `${header}\n${command}`;
}
function formatFileChangeMessage(files: FileChangeEntry[]): string {
if (!files.length) {
return "File changes";
}
const header = files.length === 1 ? "File change" : `${files.length} file changes`;
const entries = files.map((file) => {
const kindLabel = file.kind ? toHumanLabel(file.kind) : "";
return `• ${kindLabel ? `[${kindLabel}] ` : ""}${file.path}`;
});
return [header, ...entries].join("\n");
}
function formatWebSearchMessage(query: string): string {
return `Web search\n"${query.trim()}"`;
}
function formatTodoMessage(items: TodoEntry[]): string {
if (!items.length) {
return "Todo list";
@@ -278,55 +379,34 @@ export function reduceStreamUpdate(
case "timeline": {
const item = event.item;
switch (item.type) {
case "user_message":
return appendUserMessage(state, item.text, timestamp, item.messageId);
case "assistant_message":
return appendAssistantMessage(state, item.text, timestamp);
case "reasoning":
return appendThought(state, item.text, timestamp);
case "mcp_tool":
case "tool_call": {
if (isPermissionToolCall(item)) {
return state;
}
const rawPayload =
item.raw ?? { input: item.input, output: item.output, error: item.error };
return appendAgentToolCall(
state,
{
provider: event.provider,
server: item.server,
tool: item.tool,
status: item.status,
raw: item.raw,
status: item.status ?? "executing",
raw: rawPayload,
callId: item.callId,
displayName: item.displayName,
kind: item.kind,
result: item.output,
error: item.error,
},
timestamp
);
case "command": {
const activity: ActivityLogItem = {
kind: "activity_log",
id: createTimelineId("command", `${item.command}:${item.status ?? ""}`, timestamp),
timestamp,
activityType: describeCommandStatus(item.status),
message: formatCommandMessage(item.command, item.status),
metadata: item.raw ? { raw: item.raw } : undefined,
};
return appendActivityLog(state, activity);
}
case "file_change": {
const files = (item.files ?? []) as FileChangeEntry[];
const activity: ActivityLogItem = {
kind: "activity_log",
id: createTimelineId("file_change", JSON.stringify(files), timestamp),
timestamp,
activityType: "info",
message: formatFileChangeMessage(files),
metadata: files.length ? { files } : undefined,
};
return appendActivityLog(state, activity);
}
case "web_search": {
const activity: ActivityLogItem = {
kind: "activity_log",
id: createTimelineId("web_search", item.query ?? "", timestamp),
timestamp,
activityType: "info",
message: formatWebSearchMessage(item.query ?? ""),
metadata: item.raw ? { raw: item.raw } : { query: item.query },
};
return appendActivityLog(state, activity);
}
case "todo": {
const items = (item.items ?? []) as TodoEntry[];