refactor: add dynamic input height, agent sorting, and last activity tracking
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user