refactor: add dynamic input height, agent sorting, and last activity tracking

This commit is contained in:
Mohamed Boudra
2025-10-25 21:56:44 +02:00
parent 1d6de732b6
commit 8d591da671
10 changed files with 186 additions and 112 deletions
@@ -1,4 +1,10 @@
import { View, TextInput, Pressable } from "react-native";
import {
View,
TextInput,
Pressable,
NativeSyntheticEvent,
TextInputContentSizeChangeEventData,
} from "react-native";
import { useState } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, ArrowUp, AudioLines, Square } from "lucide-react-native";
@@ -10,6 +16,9 @@ interface AgentInputAreaProps {
agentId: string;
}
const MIN_INPUT_HEIGHT = 40;
const MAX_INPUT_HEIGHT = 160;
export function AgentInputArea({ agentId }: AgentInputAreaProps) {
const { theme } = useUnistyles();
const { ws, sendAgentMessage, sendAgentAudio } = useSession();
@@ -17,6 +26,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
const audioRecorder = useAudioRecorder();
const [userInput, setUserInput] = useState("");
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
const [isRecording, setIsRecording] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
@@ -25,6 +35,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
const message = userInput.trim();
setUserInput("");
setInputHeight(MIN_INPUT_HEIGHT);
setIsProcessing(true);
try {
@@ -73,6 +84,24 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
}
}
function handleContentSizeChange(
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
) {
const contentHeight = event.nativeEvent.contentSize.height;
const boundedHeight = Math.min(
MAX_INPUT_HEIGHT,
Math.max(MIN_INPUT_HEIGHT, contentHeight),
);
setInputHeight((currentHeight) => {
if (Math.abs(currentHeight - boundedHeight) < 1) {
return currentHeight;
}
return boundedHeight;
});
}
const hasText = userInput.trim().length > 0;
return (
@@ -83,8 +112,13 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
onChangeText={setUserInput}
placeholder="Message agent..."
placeholderTextColor={theme.colors.mutedForeground}
style={styles.textInput}
style={[
styles.textInput,
{ height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT },
]}
multiline
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
onContentSizeChange={handleContentSizeChange}
editable={!isRecording && ws.isConnected}
/>
@@ -143,16 +177,14 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "center",
alignItems: "flex-end",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[4],
gap: theme.spacing[3],
height: 88,
minHeight: 88,
},
textInput: {
flex: 1,
minHeight: 40,
maxHeight: 80,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.muted,
+29 -14
View File
@@ -3,6 +3,7 @@ import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { Agent } from "@/contexts/session-context";
import type { AgentStatus } from "@server/server/acp/types";
import { formatTimeAgo } from "@/utils/time";
interface AgentListProps {
agents: Map<string, Agent>;
@@ -48,7 +49,11 @@ function getStatusLabel(status: AgentStatus): string {
export function AgentList({ agents }: AgentListProps) {
const { theme } = useUnistyles();
const agentArray = Array.from(agents.values());
// Sort agents by lastActivityAt (most recent first)
const agentArray = Array.from(agents.values()).sort((a, b) => {
return b.lastActivityAt.getTime() - a.lastActivityAt.getTime();
});
function handleAgentPress(agentId: string) {
router.push(`/agent/${agentId}`);
@@ -59,6 +64,7 @@ export function AgentList({ agents }: AgentListProps) {
{agentArray.map((agent) => {
const statusColor = getStatusColor(agent.status);
const statusLabel = getStatusLabel(agent.status);
const timeAgo = formatTimeAgo(agent.lastActivityAt);
return (
<Pressable
@@ -77,14 +83,14 @@ export function AgentList({ agents }: AgentListProps) {
{agent.title || "New Agent"}
</Text>
<View style={styles.directoryRow}>
<Text
style={styles.agentDirectory}
numberOfLines={1}
>
{agent.cwd}
</Text>
<Text
style={styles.agentDirectory}
numberOfLines={1}
>
{agent.cwd}
</Text>
<View style={styles.statusRow}>
<View style={styles.statusBadge}>
<View
style={[styles.statusDot, { backgroundColor: statusColor }]}
@@ -93,6 +99,10 @@ export function AgentList({ agents }: AgentListProps) {
{statusLabel}
</Text>
</View>
<Text style={styles.timeAgo}>
{timeAgo}
</Text>
</View>
</View>
</Pressable>
@@ -128,15 +138,16 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
marginBottom: theme.spacing[1],
},
directoryRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
agentDirectory: {
flex: 1,
fontSize: theme.fontSize.sm,
color: theme.colors.mutedForeground,
marginBottom: theme.spacing[1],
},
statusRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
statusBadge: {
flexDirection: "row",
@@ -152,4 +163,8 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
timeAgo: {
fontSize: theme.fontSize.xs,
color: theme.colors.mutedForeground,
},
}));
+35 -8
View File
@@ -1,13 +1,14 @@
import { useEffect } from "react";
import { View, Pressable, StyleSheet as RNStyleSheet } from "react-native";
import { usePathname } from "expo-router";
import { useEffect, useState } from "react";
import { View, Pressable, Text } from "react-native";
import { usePathname, useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AudioLines } from "lucide-react-native";
import { AudioLines, Users, Plus } from "lucide-react-native";
import { useRealtime } from "@/contexts/realtime-context";
import { useSession } from "@/contexts/session-context";
import { useFooterControls, FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
import { RealtimeControls } from "./realtime-controls";
import { CreateAgentModal } from "./create-agent-modal";
import Animated, {
FadeIn,
FadeOut,
@@ -21,13 +22,17 @@ export function GlobalFooter() {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const pathname = usePathname();
const router = useRouter();
const { isRealtimeMode, startRealtime } = useRealtime();
const { ws } = useSession();
const { controls } = useFooterControls();
const [showCreateModal, setShowCreateModal] = useState(false);
// Determine current screen type
const isAgentScreen = pathname?.startsWith("/agent/");
const agentControlsHeight = useSharedValue(FOOTER_HEIGHT);
const hasRegisteredControls = !!controls;
const showAgentControls = isAgentScreen && hasRegisteredControls;
@@ -53,6 +58,19 @@ export function GlobalFooter() {
[bottomInset],
);
const containerAnimatedStyle = useAnimatedStyle(
() => {
"worklet";
const agentHeight = Math.max(agentControlsHeight.value, FOOTER_HEIGHT);
const expandedHeight = agentHeight - (agentHeight - FOOTER_HEIGHT) * transition.value;
return {
height: expandedHeight + bottomInset,
};
},
[bottomInset],
);
const realtimeAnimatedStyle = useAnimatedStyle(() => {
return {
opacity: transition.value,
@@ -74,19 +92,22 @@ export function GlobalFooter() {
styles.container,
{
paddingBottom: insets.bottom,
height: FOOTER_HEIGHT + insets.bottom,
},
containerAnimatedStyle,
keyboardAnimatedStyle,
]}
>
<View style={styles.content}>
<Animated.View
style={[RNStyleSheet.absoluteFillObject, agentControlsAnimatedStyle]}
onLayout={(event) => {
agentControlsHeight.value = Math.max(event.nativeEvent.layout.height, FOOTER_HEIGHT);
}}
style={[styles.absoluteBottomFill, agentControlsAnimatedStyle]}
>
{controls}
</Animated.View>
<Animated.View
style={[RNStyleSheet.absoluteFillObject, realtimeAnimatedStyle]}
style={[styles.absoluteBottomFill, realtimeAnimatedStyle]}
>
<RealtimeControls />
</Animated.View>
@@ -157,7 +178,13 @@ const styles = StyleSheet.create((theme) => ({
},
content: {
flex: 1,
height: FOOTER_HEIGHT,
minHeight: FOOTER_HEIGHT,
},
absoluteBottomFill: {
position: "absolute",
left: 0,
right: 0,
bottom: 0,
},
centeredButtonContainer: {
padding: theme.spacing[6],
+17 -1
View File
@@ -56,6 +56,7 @@ export interface Agent {
id: string;
status: AgentStatus;
createdAt: Date;
lastActivityAt: Date;
type: "claude";
sessionId: string | null;
error: string | null;
@@ -170,6 +171,9 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
new Map(
agentsList.map((agentInfo) => {
const createdAt = new Date(agentInfo.createdAt);
const lastActivityAt = agentInfo.lastActivityAt
? new Date(agentInfo.lastActivityAt)
: createdAt;
return [
agentInfo.id,
{
@@ -177,6 +181,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
status: agentInfo.status as AgentStatus,
type: agentInfo.type,
createdAt,
lastActivityAt,
title: agentInfo.title ?? null,
cwd: agentInfo.cwd,
sessionId: agentInfo.sessionId ?? null,
@@ -201,11 +206,13 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
console.log("[Session] Agent created:", agentId);
const now = new Date();
const agent: Agent = {
id: agentId,
status: status as AgentStatus,
type,
createdAt: new Date(),
createdAt: now,
lastActivityAt: now,
title: title ?? null,
cwd,
sessionId: null,
@@ -233,6 +240,9 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
setAgents((prev) => {
const next = new Map(prev);
const createdAt = new Date(info.createdAt);
const lastActivityAt = info.lastActivityAt
? new Date(info.lastActivityAt)
: createdAt;
const existing = next.get(agentId);
const normalizedAgent: Agent = {
@@ -240,6 +250,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
status: info.status as AgentStatus,
type: info.type,
createdAt,
lastActivityAt,
title: info.title ?? null,
cwd: info.cwd,
sessionId: info.sessionId ?? null,
@@ -288,6 +299,10 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
const existingAgent = prev.get(agentId);
if (!existingAgent) return prev;
const lastActivityAt = info.lastActivityAt
? new Date(info.lastActivityAt)
: existingAgent.lastActivityAt;
const updatedAgent: Agent = {
...existingAgent,
status: info.status as AgentStatus,
@@ -297,6 +312,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
availableModes: info.availableModes ?? null,
title: info.title ?? null,
cwd: info.cwd,
lastActivityAt,
};
return new Map(prev).set(agentId, updatedAgent);
+37
View File
@@ -0,0 +1,37 @@
/**
* Format a date as a human-friendly relative time string
* Examples: "just now", "5m ago", "2h ago", "3d ago", "Jan 15"
*/
export function formatTimeAgo(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 10) {
return "just now";
}
if (diffMin < 1) {
return `${diffSec}s ago`;
}
if (diffHour < 1) {
return `${diffMin}m ago`;
}
if (diffDay < 1) {
return `${diffHour}h ago`;
}
if (diffDay < 7) {
return `${diffDay}d ago`;
}
// For older dates, show abbreviated month and day
const month = date.toLocaleDateString("en-US", { month: "short" });
const day = date.getDate();
return `${month} ${day}`;
}