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 { useState } from "react";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { Mic, ArrowUp, AudioLines, Square } from "lucide-react-native";
|
import { Mic, ArrowUp, AudioLines, Square } from "lucide-react-native";
|
||||||
@@ -10,6 +16,9 @@ interface AgentInputAreaProps {
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MIN_INPUT_HEIGHT = 40;
|
||||||
|
const MAX_INPUT_HEIGHT = 160;
|
||||||
|
|
||||||
export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { ws, sendAgentMessage, sendAgentAudio } = useSession();
|
const { ws, sendAgentMessage, sendAgentAudio } = useSession();
|
||||||
@@ -17,6 +26,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
const audioRecorder = useAudioRecorder();
|
const audioRecorder = useAudioRecorder();
|
||||||
|
|
||||||
const [userInput, setUserInput] = useState("");
|
const [userInput, setUserInput] = useState("");
|
||||||
|
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
@@ -25,6 +35,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
|
|
||||||
const message = userInput.trim();
|
const message = userInput.trim();
|
||||||
setUserInput("");
|
setUserInput("");
|
||||||
|
setInputHeight(MIN_INPUT_HEIGHT);
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
|
||||||
try {
|
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;
|
const hasText = userInput.trim().length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -83,8 +112,13 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
onChangeText={setUserInput}
|
onChangeText={setUserInput}
|
||||||
placeholder="Message agent..."
|
placeholder="Message agent..."
|
||||||
placeholderTextColor={theme.colors.mutedForeground}
|
placeholderTextColor={theme.colors.mutedForeground}
|
||||||
style={styles.textInput}
|
style={[
|
||||||
|
styles.textInput,
|
||||||
|
{ height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT },
|
||||||
|
]}
|
||||||
multiline
|
multiline
|
||||||
|
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
|
||||||
|
onContentSizeChange={handleContentSizeChange}
|
||||||
editable={!isRecording && ws.isConnected}
|
editable={!isRecording && ws.isConnected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -143,16 +177,14 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "flex-end",
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
paddingVertical: theme.spacing[4],
|
paddingVertical: theme.spacing[4],
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
height: 88,
|
minHeight: 88,
|
||||||
},
|
},
|
||||||
textInput: {
|
textInput: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 40,
|
|
||||||
maxHeight: 80,
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
backgroundColor: theme.colors.muted,
|
backgroundColor: theme.colors.muted,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { router } from "expo-router";
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import type { Agent } from "@/contexts/session-context";
|
import type { Agent } from "@/contexts/session-context";
|
||||||
import type { AgentStatus } from "@server/server/acp/types";
|
import type { AgentStatus } from "@server/server/acp/types";
|
||||||
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
|
|
||||||
interface AgentListProps {
|
interface AgentListProps {
|
||||||
agents: Map<string, Agent>;
|
agents: Map<string, Agent>;
|
||||||
@@ -48,7 +49,11 @@ function getStatusLabel(status: AgentStatus): string {
|
|||||||
|
|
||||||
export function AgentList({ agents }: AgentListProps) {
|
export function AgentList({ agents }: AgentListProps) {
|
||||||
const { theme } = useUnistyles();
|
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) {
|
function handleAgentPress(agentId: string) {
|
||||||
router.push(`/agent/${agentId}`);
|
router.push(`/agent/${agentId}`);
|
||||||
@@ -59,6 +64,7 @@ export function AgentList({ agents }: AgentListProps) {
|
|||||||
{agentArray.map((agent) => {
|
{agentArray.map((agent) => {
|
||||||
const statusColor = getStatusColor(agent.status);
|
const statusColor = getStatusColor(agent.status);
|
||||||
const statusLabel = getStatusLabel(agent.status);
|
const statusLabel = getStatusLabel(agent.status);
|
||||||
|
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -77,14 +83,14 @@ export function AgentList({ agents }: AgentListProps) {
|
|||||||
{agent.title || "New Agent"}
|
{agent.title || "New Agent"}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={styles.directoryRow}>
|
<Text
|
||||||
<Text
|
style={styles.agentDirectory}
|
||||||
style={styles.agentDirectory}
|
numberOfLines={1}
|
||||||
numberOfLines={1}
|
>
|
||||||
>
|
{agent.cwd}
|
||||||
{agent.cwd}
|
</Text>
|
||||||
</Text>
|
|
||||||
|
|
||||||
|
<View style={styles.statusRow}>
|
||||||
<View style={styles.statusBadge}>
|
<View style={styles.statusBadge}>
|
||||||
<View
|
<View
|
||||||
style={[styles.statusDot, { backgroundColor: statusColor }]}
|
style={[styles.statusDot, { backgroundColor: statusColor }]}
|
||||||
@@ -93,6 +99,10 @@ export function AgentList({ agents }: AgentListProps) {
|
|||||||
{statusLabel}
|
{statusLabel}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.timeAgo}>
|
||||||
|
{timeAgo}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -128,15 +138,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
marginBottom: theme.spacing[1],
|
marginBottom: theme.spacing[1],
|
||||||
},
|
},
|
||||||
directoryRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
agentDirectory: {
|
agentDirectory: {
|
||||||
flex: 1,
|
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.mutedForeground,
|
color: theme.colors.mutedForeground,
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
statusRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
statusBadge: {
|
statusBadge: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -152,4 +163,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
},
|
},
|
||||||
|
timeAgo: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.mutedForeground,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { View, Pressable, StyleSheet as RNStyleSheet } from "react-native";
|
import { View, Pressable, Text } from "react-native";
|
||||||
import { usePathname } from "expo-router";
|
import { usePathname, useRouter } from "expo-router";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
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 { useRealtime } from "@/contexts/realtime-context";
|
||||||
import { useSession } from "@/contexts/session-context";
|
import { useSession } from "@/contexts/session-context";
|
||||||
import { useFooterControls, FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
|
import { useFooterControls, FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
|
||||||
import { RealtimeControls } from "./realtime-controls";
|
import { RealtimeControls } from "./realtime-controls";
|
||||||
|
import { CreateAgentModal } from "./create-agent-modal";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
FadeIn,
|
FadeIn,
|
||||||
FadeOut,
|
FadeOut,
|
||||||
@@ -21,13 +22,17 @@ export function GlobalFooter() {
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
const { isRealtimeMode, startRealtime } = useRealtime();
|
const { isRealtimeMode, startRealtime } = useRealtime();
|
||||||
const { ws } = useSession();
|
const { ws } = useSession();
|
||||||
const { controls } = useFooterControls();
|
const { controls } = useFooterControls();
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
|
||||||
// Determine current screen type
|
// Determine current screen type
|
||||||
const isAgentScreen = pathname?.startsWith("/agent/");
|
const isAgentScreen = pathname?.startsWith("/agent/");
|
||||||
|
|
||||||
|
const agentControlsHeight = useSharedValue(FOOTER_HEIGHT);
|
||||||
|
|
||||||
const hasRegisteredControls = !!controls;
|
const hasRegisteredControls = !!controls;
|
||||||
const showAgentControls = isAgentScreen && hasRegisteredControls;
|
const showAgentControls = isAgentScreen && hasRegisteredControls;
|
||||||
|
|
||||||
@@ -53,6 +58,19 @@ export function GlobalFooter() {
|
|||||||
[bottomInset],
|
[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(() => {
|
const realtimeAnimatedStyle = useAnimatedStyle(() => {
|
||||||
return {
|
return {
|
||||||
opacity: transition.value,
|
opacity: transition.value,
|
||||||
@@ -74,19 +92,22 @@ export function GlobalFooter() {
|
|||||||
styles.container,
|
styles.container,
|
||||||
{
|
{
|
||||||
paddingBottom: insets.bottom,
|
paddingBottom: insets.bottom,
|
||||||
height: FOOTER_HEIGHT + insets.bottom,
|
|
||||||
},
|
},
|
||||||
|
containerAnimatedStyle,
|
||||||
keyboardAnimatedStyle,
|
keyboardAnimatedStyle,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[RNStyleSheet.absoluteFillObject, agentControlsAnimatedStyle]}
|
onLayout={(event) => {
|
||||||
|
agentControlsHeight.value = Math.max(event.nativeEvent.layout.height, FOOTER_HEIGHT);
|
||||||
|
}}
|
||||||
|
style={[styles.absoluteBottomFill, agentControlsAnimatedStyle]}
|
||||||
>
|
>
|
||||||
{controls}
|
{controls}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[RNStyleSheet.absoluteFillObject, realtimeAnimatedStyle]}
|
style={[styles.absoluteBottomFill, realtimeAnimatedStyle]}
|
||||||
>
|
>
|
||||||
<RealtimeControls />
|
<RealtimeControls />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -157,7 +178,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: FOOTER_HEIGHT,
|
minHeight: FOOTER_HEIGHT,
|
||||||
|
},
|
||||||
|
absoluteBottomFill: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
},
|
},
|
||||||
centeredButtonContainer: {
|
centeredButtonContainer: {
|
||||||
padding: theme.spacing[6],
|
padding: theme.spacing[6],
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export interface Agent {
|
|||||||
id: string;
|
id: string;
|
||||||
status: AgentStatus;
|
status: AgentStatus;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
lastActivityAt: Date;
|
||||||
type: "claude";
|
type: "claude";
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -170,6 +171,9 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
new Map(
|
new Map(
|
||||||
agentsList.map((agentInfo) => {
|
agentsList.map((agentInfo) => {
|
||||||
const createdAt = new Date(agentInfo.createdAt);
|
const createdAt = new Date(agentInfo.createdAt);
|
||||||
|
const lastActivityAt = agentInfo.lastActivityAt
|
||||||
|
? new Date(agentInfo.lastActivityAt)
|
||||||
|
: createdAt;
|
||||||
return [
|
return [
|
||||||
agentInfo.id,
|
agentInfo.id,
|
||||||
{
|
{
|
||||||
@@ -177,6 +181,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
status: agentInfo.status as AgentStatus,
|
status: agentInfo.status as AgentStatus,
|
||||||
type: agentInfo.type,
|
type: agentInfo.type,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
lastActivityAt,
|
||||||
title: agentInfo.title ?? null,
|
title: agentInfo.title ?? null,
|
||||||
cwd: agentInfo.cwd,
|
cwd: agentInfo.cwd,
|
||||||
sessionId: agentInfo.sessionId ?? null,
|
sessionId: agentInfo.sessionId ?? null,
|
||||||
@@ -201,11 +206,13 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
|
|
||||||
console.log("[Session] Agent created:", agentId);
|
console.log("[Session] Agent created:", agentId);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
const agent: Agent = {
|
const agent: Agent = {
|
||||||
id: agentId,
|
id: agentId,
|
||||||
status: status as AgentStatus,
|
status: status as AgentStatus,
|
||||||
type,
|
type,
|
||||||
createdAt: new Date(),
|
createdAt: now,
|
||||||
|
lastActivityAt: now,
|
||||||
title: title ?? null,
|
title: title ?? null,
|
||||||
cwd,
|
cwd,
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
@@ -233,6 +240,9 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
setAgents((prev) => {
|
setAgents((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
const createdAt = new Date(info.createdAt);
|
const createdAt = new Date(info.createdAt);
|
||||||
|
const lastActivityAt = info.lastActivityAt
|
||||||
|
? new Date(info.lastActivityAt)
|
||||||
|
: createdAt;
|
||||||
const existing = next.get(agentId);
|
const existing = next.get(agentId);
|
||||||
|
|
||||||
const normalizedAgent: Agent = {
|
const normalizedAgent: Agent = {
|
||||||
@@ -240,6 +250,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
status: info.status as AgentStatus,
|
status: info.status as AgentStatus,
|
||||||
type: info.type,
|
type: info.type,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
lastActivityAt,
|
||||||
title: info.title ?? null,
|
title: info.title ?? null,
|
||||||
cwd: info.cwd,
|
cwd: info.cwd,
|
||||||
sessionId: info.sessionId ?? null,
|
sessionId: info.sessionId ?? null,
|
||||||
@@ -288,6 +299,10 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
const existingAgent = prev.get(agentId);
|
const existingAgent = prev.get(agentId);
|
||||||
if (!existingAgent) return prev;
|
if (!existingAgent) return prev;
|
||||||
|
|
||||||
|
const lastActivityAt = info.lastActivityAt
|
||||||
|
? new Date(info.lastActivityAt)
|
||||||
|
: existingAgent.lastActivityAt;
|
||||||
|
|
||||||
const updatedAgent: Agent = {
|
const updatedAgent: Agent = {
|
||||||
...existingAgent,
|
...existingAgent,
|
||||||
status: info.status as AgentStatus,
|
status: info.status as AgentStatus,
|
||||||
@@ -297,6 +312,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
availableModes: info.availableModes ?? null,
|
availableModes: info.availableModes ?? null,
|
||||||
title: info.title ?? null,
|
title: info.title ?? null,
|
||||||
cwd: info.cwd,
|
cwd: info.cwd,
|
||||||
|
lastActivityAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Map(prev).set(agentId, updatedAgent);
|
return new Map(prev).set(agentId, updatedAgent);
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -1,79 +1,14 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "b203dc68-03e0-4971-a3d7-a4822c940b50",
|
"id": "5d28d04a-1b29-43fd-b27e-bcaa32ee453d",
|
||||||
"title": "Agent b203dc68",
|
"title": "Add Three Global Footer Buttons",
|
||||||
"sessionId": "07f421be-98c1-4f23-9604-8f00274956c7",
|
"sessionId": "147f5a79-e310-4bfd-86fa-0c6a011cb7c7",
|
||||||
"options": {
|
"options": {
|
||||||
"type": "claude",
|
"type": "claude",
|
||||||
"sessionId": "07f421be-98c1-4f23-9604-8f00274956c7"
|
"sessionId": "147f5a79-e310-4bfd-86fa-0c6a011cb7c7"
|
||||||
},
|
},
|
||||||
"createdAt": "2025-10-24T15:53:55.340Z",
|
"createdAt": "2025-10-25T19:50:58.711Z",
|
||||||
|
"lastActivityAt": "2025-10-25T19:50:58.711Z",
|
||||||
"cwd": "/Users/moboudra/dev/voice-dev"
|
"cwd": "/Users/moboudra/dev/voice-dev"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "d0633729-2f74-426a-a3a8-05e8c7206703",
|
|
||||||
"title": "Assist with Voice Development",
|
|
||||||
"sessionId": "79c9a8e0-8a24-4851-baad-62747b815442",
|
|
||||||
"options": {
|
|
||||||
"type": "claude",
|
|
||||||
"sessionId": "79c9a8e0-8a24-4851-baad-62747b815442"
|
|
||||||
},
|
|
||||||
"createdAt": "2025-10-24T15:57:04.442Z",
|
|
||||||
"cwd": "/Users/moboudra/dev/voice-dev"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "50128b81-017c-4ab5-8901-3d3f6b65898c",
|
|
||||||
"title": "Agent 50128b81",
|
|
||||||
"sessionId": "019a1c46-f0ea-76f4-8c1d-6c5d568f7b05",
|
|
||||||
"options": {
|
|
||||||
"type": "claude",
|
|
||||||
"sessionId": null
|
|
||||||
},
|
|
||||||
"createdAt": "2025-10-25T10:48:01.416Z",
|
|
||||||
"cwd": "/Users/moboudra/dev/faro/main"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cf327185-2307-4744-b2d0-c50f8bfa8f55",
|
|
||||||
"title": "Agent cf327185",
|
|
||||||
"sessionId": "019a1b66-dbf2-76b0-b578-60abfbd65d22",
|
|
||||||
"options": {
|
|
||||||
"type": "claude",
|
|
||||||
"sessionId": null
|
|
||||||
},
|
|
||||||
"createdAt": "2025-10-25T12:37:28.278Z",
|
|
||||||
"cwd": "/Users/moboudra/dev/faro/main"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "9af00580-418b-4053-a942-a8d168ab5703",
|
|
||||||
"title": "Initial User Greeting",
|
|
||||||
"sessionId": "7adb8247-5ffe-44a0-ae8b-177a5611fe6c",
|
|
||||||
"options": {
|
|
||||||
"type": "claude",
|
|
||||||
"sessionId": "7adb8247-5ffe-44a0-ae8b-177a5611fe6c"
|
|
||||||
},
|
|
||||||
"createdAt": "2025-10-25T12:44:52.183Z",
|
|
||||||
"cwd": "/Users/moboudra/dev/faro/main"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "415c2eb6-6649-4f3f-aee5-acc3d6b2cfd4",
|
|
||||||
"title": "Agent 415c2eb6",
|
|
||||||
"sessionId": "019a1c5a-0929-76de-ad58-1b80fa056209",
|
|
||||||
"options": {
|
|
||||||
"type": "claude",
|
|
||||||
"sessionId": null
|
|
||||||
},
|
|
||||||
"createdAt": "2025-10-25T17:10:52.912Z",
|
|
||||||
"cwd": "/Users/moboudra/dev/faro/main"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cac8f9e6-3b3b-4916-8e04-2b2ea8053f22",
|
|
||||||
"title": "Review Open Pull Requests",
|
|
||||||
"sessionId": "5f3938aa-bd06-47f2-b09e-b6bae76f7ffc",
|
|
||||||
"options": {
|
|
||||||
"type": "claude",
|
|
||||||
"sessionId": "5f3938aa-bd06-47f2-b09e-b6bae76f7ffc"
|
|
||||||
},
|
|
||||||
"createdAt": "2025-10-25T17:43:48.309Z",
|
|
||||||
"cwd": "/Users/moboudra/dev/faro/main"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -42,6 +42,7 @@ interface ManagedAgent {
|
|||||||
id: string;
|
id: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
lastActivityAt: Date;
|
||||||
title: string;
|
title: string;
|
||||||
options: AgentOptions;
|
options: AgentOptions;
|
||||||
subscribers: Set<AgentUpdateCallback>;
|
subscribers: Set<AgentUpdateCallback>;
|
||||||
@@ -166,10 +167,15 @@ export class AgentManager {
|
|||||||
console.log(
|
console.log(
|
||||||
`[AgentManager] Loading agent ${persistedAgent.id} as uninitialized`
|
`[AgentManager] Loading agent ${persistedAgent.id} as uninitialized`
|
||||||
);
|
);
|
||||||
|
const createdAt = new Date(persistedAgent.createdAt);
|
||||||
|
const lastActivityAt = persistedAgent.lastActivityAt
|
||||||
|
? new Date(persistedAgent.lastActivityAt)
|
||||||
|
: createdAt;
|
||||||
const agent: ManagedAgent = {
|
const agent: ManagedAgent = {
|
||||||
id: persistedAgent.id,
|
id: persistedAgent.id,
|
||||||
cwd: expandTilde(persistedAgent.cwd),
|
cwd: expandTilde(persistedAgent.cwd),
|
||||||
createdAt: new Date(persistedAgent.createdAt),
|
createdAt,
|
||||||
|
lastActivityAt,
|
||||||
title: persistedAgent.title,
|
title: persistedAgent.title,
|
||||||
options: persistedAgent.options,
|
options: persistedAgent.options,
|
||||||
subscribers: new Set(),
|
subscribers: new Set(),
|
||||||
@@ -225,6 +231,7 @@ export class AgentManager {
|
|||||||
id: agentId,
|
id: agentId,
|
||||||
cwd,
|
cwd,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
lastActivityAt: createdAt,
|
||||||
title: "",
|
title: "",
|
||||||
options: agentOptions,
|
options: agentOptions,
|
||||||
subscribers: new Set(),
|
subscribers: new Set(),
|
||||||
@@ -250,6 +257,7 @@ export class AgentManager {
|
|||||||
sessionId: null,
|
sessionId: null,
|
||||||
options: agent.options,
|
options: agent.options,
|
||||||
createdAt: createdAt.toISOString(),
|
createdAt: createdAt.toISOString(),
|
||||||
|
lastActivityAt: agent.lastActivityAt.toISOString(),
|
||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -553,6 +561,7 @@ export class AgentManager {
|
|||||||
id: agent.id,
|
id: agent.id,
|
||||||
status,
|
status,
|
||||||
createdAt: agent.createdAt,
|
createdAt: agent.createdAt,
|
||||||
|
lastActivityAt: agent.lastActivityAt,
|
||||||
type: "claude" as const,
|
type: "claude" as const,
|
||||||
sessionId,
|
sessionId,
|
||||||
error: error ?? null,
|
error: error ?? null,
|
||||||
@@ -617,6 +626,7 @@ export class AgentManager {
|
|||||||
id: agent.id,
|
id: agent.id,
|
||||||
status,
|
status,
|
||||||
createdAt: agent.createdAt,
|
createdAt: agent.createdAt,
|
||||||
|
lastActivityAt: agent.lastActivityAt,
|
||||||
type: "claude",
|
type: "claude",
|
||||||
sessionId: runtime?.sessionId ?? null,
|
sessionId: runtime?.sessionId ?? null,
|
||||||
error: error ?? null,
|
error: error ?? null,
|
||||||
@@ -1000,6 +1010,7 @@ export class AgentManager {
|
|||||||
sessionId: runtime.sessionId,
|
sessionId: runtime.sessionId,
|
||||||
options: agent.options,
|
options: agent.options,
|
||||||
createdAt: agent.createdAt.toISOString(),
|
createdAt: agent.createdAt.toISOString(),
|
||||||
|
lastActivityAt: agent.lastActivityAt.toISOString(),
|
||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1056,6 +1067,9 @@ export class AgentManager {
|
|||||||
const agent = this.agents.get(agentId);
|
const agent = this.agents.get(agentId);
|
||||||
if (!agent) return;
|
if (!agent) return;
|
||||||
|
|
||||||
|
// Update last activity timestamp
|
||||||
|
agent.lastActivityAt = new Date();
|
||||||
|
|
||||||
// Augment update with stable message IDs for deduplication
|
// Augment update with stable message IDs for deduplication
|
||||||
let enrichedUpdate: EnrichedSessionNotification;
|
let enrichedUpdate: EnrichedSessionNotification;
|
||||||
|
|
||||||
@@ -1286,17 +1300,6 @@ export class AgentManager {
|
|||||||
console.log(
|
console.log(
|
||||||
`[Agent ${agentId}] Permission request emitted: ${requestId}`
|
`[Agent ${agentId}] Permission request emitted: ${requestId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set a timeout to auto-reject after 5 minutes if no response
|
|
||||||
setTimeout(() => {
|
|
||||||
if (agent.pendingPermissions.has(requestId)) {
|
|
||||||
console.warn(
|
|
||||||
`[Agent ${agentId}] Permission request ${requestId} timed out`
|
|
||||||
);
|
|
||||||
agent.pendingPermissions.delete(requestId);
|
|
||||||
reject(new Error("Permission request timed out"));
|
|
||||||
}
|
|
||||||
}, 5 * 60 * 1000); // 5 minutes
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1459,6 +1462,7 @@ export class AgentManager {
|
|||||||
sessionId: runtime.sessionId,
|
sessionId: runtime.sessionId,
|
||||||
options: agent.options,
|
options: agent.options,
|
||||||
createdAt: agent.createdAt.toISOString(),
|
createdAt: agent.createdAt.toISOString(),
|
||||||
|
lastActivityAt: agent.lastActivityAt.toISOString(),
|
||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
});
|
});
|
||||||
console.log(`[Agent ${agent.id}] State persisted`);
|
console.log(`[Agent ${agent.id}] State persisted`);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const PersistedAgentSchema = z.object({
|
|||||||
sessionId: z.string().nullable(), // ACP protocol session ID (null for uninitialized agents)
|
sessionId: z.string().nullable(), // ACP protocol session ID (null for uninitialized agents)
|
||||||
options: AgentOptionsSchema, // Required field with discriminated union
|
options: AgentOptionsSchema, // Required field with discriminated union
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
|
lastActivityAt: z.string().optional(), // Migration: optional for backward compatibility
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,6 +53,11 @@ export class AgentPersistence {
|
|||||||
try {
|
try {
|
||||||
const validated = PersistedAgentSchema.parse(agent);
|
const validated = PersistedAgentSchema.parse(agent);
|
||||||
|
|
||||||
|
// Migration: if lastActivityAt is missing, default to createdAt
|
||||||
|
if (!validated.lastActivityAt) {
|
||||||
|
validated.lastActivityAt = validated.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
validated.options.type === "claude" &&
|
validated.options.type === "claude" &&
|
||||||
validated.options.sessionId !== null
|
validated.options.sessionId !== null
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export interface AgentInfo {
|
|||||||
id: string;
|
id: string;
|
||||||
status: AgentStatus;
|
status: AgentStatus;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
lastActivityAt: Date;
|
||||||
type: "claude";
|
type: "claude";
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const AgentInfoSchema = z.object({
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
|
lastActivityAt: z.date(),
|
||||||
type: z.literal("claude"),
|
type: z.literal("claude"),
|
||||||
sessionId: z.string().nullable(),
|
sessionId: z.string().nullable(),
|
||||||
error: z.string().nullable(),
|
error: z.string().nullable(),
|
||||||
|
|||||||
Reference in New Issue
Block a user