feat: improve agent stream view UI, fix history replay timestamps, and enhance planner/worker loop

This commit is contained in:
Mohamed Boudra
2026-01-15 15:10:08 +07:00
parent 5289be9c06
commit ef853914d5
12 changed files with 179 additions and 107 deletions
+51 -26
View File
@@ -1,11 +1,22 @@
import { View, Text, Pressable, Modal, RefreshControl, FlatList, type ListRenderItem } from "react-native";
import {
View,
Text,
Pressable,
Modal,
RefreshControl,
FlatList,
type ListRenderItem,
} from "react-native";
import { useCallback, useState, type ReactElement } from "react";
import { router, usePathname } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { formatTimeAgo } from "@/utils/time";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { buildAgentNavigationKey, startNavigationTiming } from "@/utils/navigation-timing";
import {
buildAgentNavigationKey,
startNavigationTiming,
} from "@/utils/navigation-timing";
interface AgentListProps {
agents: AggregatedAgent[];
@@ -30,7 +41,9 @@ export function AgentList({
// Get the methods for the specific server
const methods = useSessionStore((state) =>
actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.methods : undefined
actionAgent?.serverId
? state.sessions[actionAgent.serverId]?.methods
: undefined
);
const deleteAgent = methods?.deleteAgent;
@@ -94,7 +107,11 @@ export function AgentList({
const isRunning = agent.status === "running";
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
const statusColor = isRunning ? theme.colors.palette.blue[500] : agent.requiresAttention ? theme.colors.success : null;
const statusColor = isRunning
? theme.colors.palette.blue[500]
: agent.requiresAttention
? theme.colors.success
: null;
return (
<Pressable
@@ -108,26 +125,28 @@ export function AgentList({
onLongPress={() => handleAgentLongPress(agent)}
>
{({ hovered }) => (
<View style={styles.agentContent}>
<View style={styles.row}>
{statusColor && (
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
)}
<Text
style={[
styles.agentTitle,
(isSelected || hovered) && styles.agentTitleHighlighted,
]}
numberOfLines={1}
>
{agent.title || "New Agent"}
<View style={styles.agentContent}>
<View style={styles.row}>
{statusColor && (
<View
style={[styles.statusDot, { backgroundColor: statusColor }]}
/>
)}
<Text
style={[
styles.agentTitle,
(isSelected || hovered) && styles.agentTitleHighlighted,
]}
numberOfLines={1}
>
{agent.title || "New Agent"}
</Text>
</View>
<Text style={styles.secondaryRow} numberOfLines={1}>
{agent.cwd.replace(/^\/(?:Users|home)\/[^/]+/, "~")} · {timeAgo}
</Text>
</View>
<Text style={styles.secondaryRow} numberOfLines={1}>
{agent.cwd.replace(/^\/(?:Users|home)\/[^/]+/, "~")} · {timeAgo}
</Text>
</View>
)}
</Pressable>
);
@@ -176,7 +195,10 @@ export function AgentList({
onRequestClose={handleCloseActionSheet}
>
<View style={styles.sheetOverlay}>
<Pressable style={styles.sheetBackdrop} onPress={handleCloseActionSheet} />
<Pressable
style={styles.sheetBackdrop}
onPress={handleCloseActionSheet}
/>
<View style={styles.sheetContainer}>
<View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}>
@@ -195,7 +217,8 @@ export function AgentList({
<Text
style={[
styles.sheetDeleteText,
(!deleteAgent || isActionDaemonUnavailable) && styles.sheetDeleteTextDisabled,
(!deleteAgent || isActionDaemonUnavailable) &&
styles.sheetDeleteTextDisabled,
]}
>
{isActionDaemonUnavailable ? "Host offline" : "Delete agent"}
@@ -246,7 +269,7 @@ const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
gap: theme.spacing[0],
},
agentTitle: {
flex: 1,
@@ -255,10 +278,12 @@ const styles = StyleSheet.create((theme) => ({
xs: "400",
md: "300",
},
color: theme.colors.foregroundMuted,
color: theme.colors.foreground,
opacity: 0.8,
},
agentTitleHighlighted: {
color: theme.colors.foreground,
opacity: 1,
},
secondaryRow: {
fontSize: theme.fontSize.sm,
@@ -373,8 +373,6 @@ export function AgentStreamView({
return null;
}
const gap = getGapAbove(item, index);
// Check if this is the end of a turn (before a user message or end of stream when not running)
// In inverted list: index-1 is the next item (newer in time)
const nextItem = flatListData[index - 1];
@@ -386,18 +384,19 @@ export function AgentStreamView({
const getContent = () => collectTurnContent(index);
return (
<View
style={[
stylesheet.streamItemWrapper,
gap ? { marginBottom: gap } : null,
]}
>
<View style={[stylesheet.streamItemWrapper]}>
{content}
{isEndOfTurn ? <TurnCopyButton getContent={getContent} /> : null}
</View>
);
},
[getGapAbove, renderStreamItemContent, flatListData, agent.status, collectTurnContent]
[
getGapAbove,
renderStreamItemContent,
flatListData,
agent.status,
collectTurnContent,
]
);
const pendingPermissionItems = useMemo(
@@ -419,7 +418,10 @@ export function AgentStreamView({
}
const delta = Math.abs(totalCount - prevCount);
streamItemCountRef.current = totalCount;
if (totalCount < STREAM_ITEM_LOG_MIN_COUNT && delta < STREAM_ITEM_LOG_DELTA_THRESHOLD) {
if (
totalCount < STREAM_ITEM_LOG_MIN_COUNT &&
delta < STREAM_ITEM_LOG_DELTA_THRESHOLD
) {
return;
}
let userCount = 0;
@@ -452,7 +454,10 @@ export function AgentStreamView({
break;
}
}
const metrics = totalCount >= STREAM_ITEM_LOG_MIN_COUNT ? measurePayload(streamItems) : null;
const metrics =
totalCount >= STREAM_ITEM_LOG_MIN_COUNT
? measurePayload(streamItems)
: null;
perfLog(AGENT_STREAM_LOG_TAG, {
event: "stream_items",
agentId,
@@ -945,11 +950,10 @@ function PermissionRequestCard({
const handleResponse = useCallback(
(response: AgentPermissionResponse) => {
respondToPermission({
agentId: permission.agentId,
requestId: permission.request.id,
response,
})
.catch((error) => {
agentId: permission.agentId,
requestId: permission.request.id,
response,
}).catch((error) => {
console.error(
"[PermissionRequestCard] Failed to respond to permission:",
error
+25 -28
View File
@@ -1,4 +1,11 @@
import { View, Text, Pressable, Animated } from "react-native";
import {
View,
Text,
Pressable,
Animated,
StyleProp,
ViewStyle,
} from "react-native";
import {
useState,
useEffect,
@@ -31,6 +38,7 @@ import {
Search,
Brain,
Copy,
TriangleAlertIcon,
} from "lucide-react-native";
import {
StyleSheet,
@@ -173,14 +181,11 @@ export const UserMessage = memo(function UserMessage({
<View
style={[
userMessageStylesheet.container,
!resolvedDisableOuterSpacing &&
userMessageStylesheet.containerSpacing,
!resolvedDisableOuterSpacing &&
isFirstInGroup &&
userMessageStylesheet.containerFirstInGroup,
!resolvedDisableOuterSpacing &&
isLastInGroup &&
userMessageStylesheet.containerLastInGroup,
isFirstInGroup && { marginTop: theme.spacing[4] },
isLastInGroup && { marginBottom: theme.spacing[4] },
!isFirstInGroup || !isLastInGroup
? { marginBottom: theme.spacing[1] }
: undefined,
]}
>
<Pressable
@@ -364,7 +369,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
label: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
fontWeight: theme.fontWeight.normal,
flexShrink: 0,
},
secondaryLabel: {
@@ -804,8 +809,7 @@ export const ActivityLog = memo(function ActivityLog({
disabled={!isInteractive}
style={[
activityLogStylesheet.pressable,
!resolvedDisableOuterSpacing &&
activityLogStylesheet.pressableSpacing,
!resolvedDisableOuterSpacing && activityLogStylesheet.pressableSpacing,
config.bg,
isInteractive && activityLogStylesheet.pressableActive,
]}
@@ -985,8 +989,7 @@ export const TodoListCard = memo(function TodoListCard({
<View
style={[
todoListCardStylesheet.container,
!resolvedDisableOuterSpacing &&
todoListCardStylesheet.containerSpacing,
!resolvedDisableOuterSpacing && todoListCardStylesheet.containerSpacing,
]}
>
<View style={todoListCardStylesheet.card}>
@@ -1056,6 +1059,7 @@ interface ExpandableBadgeProps {
secondaryLabel?: string;
icon?: ComponentType<{ size?: number; color?: string }>;
isExpanded: boolean;
style?: StyleProp<ViewStyle>;
onToggle?: () => void;
renderDetails?: () => ReactNode;
isLoading?: boolean;
@@ -1066,6 +1070,7 @@ interface ExpandableBadgeProps {
const ExpandableBadge = memo(function ExpandableBadge({
label,
style,
secondaryLabel,
icon,
isExpanded,
@@ -1118,7 +1123,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
const IconComponent = icon;
const iconColor = isError
? theme.colors.destructive
: theme.colors.foreground;
: theme.colors.mutedForeground;
let iconNode: ReactNode = null;
if (isLoading) {
@@ -1128,22 +1133,13 @@ const ExpandableBadge = memo(function ExpandableBadge({
</Animated.View>
);
} else if (isError) {
iconNode = <X size={12} color={iconColor} />;
iconNode = <TriangleAlertIcon size={12} color={iconColor} opacity={0.8} />;
} else if (IconComponent) {
iconNode = <IconComponent size={12} color={iconColor} />;
}
return (
<View
style={[
expandableBadgeStylesheet.container,
!resolvedDisableOuterSpacing &&
expandableBadgeStylesheet.containerSpacing,
!resolvedDisableOuterSpacing &&
isLastInSequence &&
expandableBadgeStylesheet.containerLastInSequence,
]}
>
<View style={[expandableBadgeStylesheet.container, style]}>
<Pressable
onPress={hasDetails ? onToggle : undefined}
disabled={!hasDetails}
@@ -1507,12 +1503,13 @@ export const ToolCall = memo(function ToolCall({
hasDetails && !isMobile
? renderDetails
: hasDetails
? () => null
: undefined
? () => null
: undefined
}
isLoading={status === "executing"}
isError={status === "failed"}
isLastInSequence={isLastInSequence}
style={isLastInSequence ? undefined : { marginBottom: theme.spacing[1] }}
disableOuterSpacing={disableOuterSpacing}
/>
);
+3 -1
View File
@@ -67,7 +67,9 @@ function updateFavicon(status: FaviconStatus, colorScheme: ColorScheme) {
}
function getSystemColorScheme(): ColorScheme {
if (typeof window === "undefined") return "dark";
if (Platform.OS !== "web" || typeof window === "undefined" || typeof window.matchMedia !== "function") {
return "dark";
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
+1 -1
View File
@@ -18,7 +18,7 @@ StyleSheet.configure({
xl: 1200,
},
settings: {
initialTheme: "dark",
adaptiveThemes: true,
},
});