diff --git a/.github/workflows/deploy-manual.yml b/.github/workflows/deploy-manual.yml new file mode 100644 index 000000000..f0899507a --- /dev/null +++ b/.github/workflows/deploy-manual.yml @@ -0,0 +1,31 @@ +name: Deploy to Server + +run-name: Deploy ${{ github.sha }} + +on: + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + port: ${{ secrets.SSH_PORT || 22 }} + script: | + cd ~/sphere + echo "=== Previous commit ===" + git rev-parse HEAD + git rev-parse --short HEAD + git pull origin main + echo "=== Current commit ===" + git rev-parse HEAD + git rev-parse --short HEAD + docker compose build + docker compose up -d + docker compose ps diff --git a/src/components/agents/shared/AgentChat.tsx b/src/components/agents/shared/AgentChat.tsx index e5ab90992..8b22e0b89 100644 --- a/src/components/agents/shared/AgentChat.tsx +++ b/src/components/agents/shared/AgentChat.tsx @@ -125,6 +125,7 @@ export function AgentChat({ const { sessions, currentSession: historySession, // Session created when saving messages + loadSession, deleteSession, clearAllHistory, resetCurrentSession, @@ -446,11 +447,14 @@ export function AgentChat({ // Load a previous chat session via URL navigation // TanStack Query handles the actual data loading const handleLoadSession = useCallback((sessionId: string) => { - // Navigate to session - TanStack Query will load the data + // IMPORTANT: Load session into useChatHistory first to set currentSessionRef + // This prevents saveCurrentMessages from creating a duplicate session + loadSession(sessionId); + // Navigate to session - TanStack Query will load the data for display navigateToSession(sessionId); // Close sidebar on mobile setSidebarOpen(false); - }, [navigateToSession]); + }, [loadSession, navigateToSession]); const handleDeleteSession = async (sessionId: string) => { const wasCurrentSession = currentSession?.id === sessionId; diff --git a/src/components/agents/shared/QuickActions.tsx b/src/components/agents/shared/QuickActions.tsx index ee4a0b862..79c0ea628 100644 --- a/src/components/agents/shared/QuickActions.tsx +++ b/src/components/agents/shared/QuickActions.tsx @@ -23,13 +23,13 @@ export function QuickActions({ actions, onAction, disabled = false }: QuickActio
{/* Regular actions */} {regularActions.length > 0 && ( -
+
{regularActions.map((action) => ( diff --git a/src/components/wallet/L1/components/ConnectionStatus.tsx b/src/components/wallet/L1/components/ConnectionStatus.tsx new file mode 100644 index 000000000..3d6177323 --- /dev/null +++ b/src/components/wallet/L1/components/ConnectionStatus.tsx @@ -0,0 +1,168 @@ +import { motion } from "framer-motion"; +import { Loader2, WifiOff, RefreshCw, XCircle } from "lucide-react"; +import type { ConnectionState } from "../hooks/useConnectionStatus"; + +interface ConnectionStatusProps { + state: ConnectionState; + message: string; + error?: string; + onRetry: () => void; + onCancel: () => void; +} + +export function ConnectionStatus({ + state, + message, + error, + onRetry, + onCancel, +}: ConnectionStatusProps) { + // Don't show anything when connected + if (state === "connected") { + return null; + } + + const isConnecting = state === "connecting"; + const isError = state === "error"; + + return ( +
+ + {/* Animated spinner like logout/onboarding */} + {isConnecting ? ( +
+ {/* Outer Ring */} + + {/* Middle Ring */} + + {/* Inner Glow */} +
+ {/* Center Icon */} +
+ + + +
+
+ ) : isError ? ( + + + + ) : ( + + + + )} + + {/* Status Title */} +

+ {state === "connecting" && "Connecting to Blockchain"} + {state === "error" && "Connection Failed"} + {state === "disconnected" && "Disconnected"} +

+ + {/* Status Message */} +

+ {message} +

+ + {/* Status message with pulsing dot for connecting state */} + {isConnecting && ( + + + Establishing connection... + + )} + + {/* Error details */} + {error && isError && ( +
+

+ {error} +

+
+ )} + + {/* Action buttons */} +
+ {(isError || state === "disconnected") && ( + + + Retry Connection + + )} + + {isConnecting && ( + + + Cancel + + )} +
+ + {/* Server info */} +

+ Fulcrum Server: fulcrum.unicity.network:50004 +

+
+
+ ); +} diff --git a/src/components/wallet/L1/components/VestingDisplay.tsx b/src/components/wallet/L1/components/VestingDisplay.tsx index 9b658de44..02ea3c5cf 100644 --- a/src/components/wallet/L1/components/VestingDisplay.tsx +++ b/src/components/wallet/L1/components/VestingDisplay.tsx @@ -1,20 +1,6 @@ -import { motion } from "framer-motion"; import { Loader2 } from "lucide-react"; import type { VestingBalances } from "../sdk/types"; -// Skeleton shimmer component -function BalanceSkeleton() { - return ( -
- -
- ); -} - interface VestingDisplayProps { showBalances?: boolean; balances?: VestingBalances; @@ -38,20 +24,14 @@ export function VestingDisplay({
{/* Vested */}
-
- Vested - {isClassifying && ( - - )} -
+ + Vested + {isClassifying && } + {showBalances ? ( - isClassifying ? ( - - ) : ( - - {formatBalance(vestedBalance)} - - ) + + {formatBalance(vestedBalance)} + ) : ( •••••• )} @@ -59,20 +39,14 @@ export function VestingDisplay({ {/* Unvested */}
-
- Unvested - {isClassifying && ( - - )} -
+ + Unvested + {isClassifying && } + {showBalances ? ( - isClassifying ? ( - - ) : ( - - {formatBalance(unvestedBalance)} - - ) + + {formatBalance(unvestedBalance)} + ) : ( •••••• )} diff --git a/src/components/wallet/L1/components/modals/SendModal.tsx b/src/components/wallet/L1/components/modals/SendModal.tsx index 55e89f422..066b29848 100644 --- a/src/components/wallet/L1/components/modals/SendModal.tsx +++ b/src/components/wallet/L1/components/modals/SendModal.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { X, ArrowRight } from "lucide-react"; import { motion } from "framer-motion"; import { vestingState } from "../../sdk/vestingState"; +import type { VestingMode, VestingBalances } from "../../sdk/types"; interface SendModalProps { show: boolean; @@ -14,15 +15,32 @@ export function SendModal({ show, selectedAddress, onClose, onSend }: SendModalP const [destination, setDestination] = useState(""); const [amount, setAmount] = useState(""); const [error, setError] = useState(null); + const [vestingMode, setVestingMode] = useState("all"); + const [balances, setBalances] = useState({ vested: 0n, unvested: 0n, all: 0n }); - // Reset on close + // Reset on close and sync balances when modal opens useEffect(() => { if (!show) { setDestination(""); setAmount(""); setError(null); + setVestingMode("all"); + } else { + // Sync balances when modal opens + setBalances(vestingState.getAllBalances(selectedAddress)); } - }, [show]); + }, [show, selectedAddress]); + + const handleModeChange = (mode: VestingMode) => { + vestingState.setMode(mode); + setVestingMode(mode); + // Reset amount when mode changes + setAmount(""); + }; + + const getCurrentBalance = (): bigint => { + return balances[vestingMode]; + }; const handleSend = async () => { if (!destination.trim() || !amount.trim()) { @@ -83,75 +101,114 @@ export function SendModal({ show, selectedAddress, onClose, onSend }: SendModalP initial={{ x: 20, opacity: 0 }} animate={{ x: 0, opacity: 1 }} > -
- - setDestination(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSend()} - className="w-full bg-neutral-100 dark:bg-neutral-900 border border-neutral-200 dark:border-white/10 rounded-xl py-3 px-4 text-neutral-900 dark:text-white focus:border-green-500 outline-none font-mono text-sm" - placeholder="Enter wallet address" - /> -
+ {/* Vesting Mode Selector */} +
+ +
+ {([ + { value: 'all' as VestingMode, label: 'All', balance: balances.all }, + { value: 'vested' as VestingMode, label: 'Vested', balance: balances.vested }, + { value: 'unvested' as VestingMode, label: 'Unvested', balance: balances.unvested }, + ]).map((option) => { + const isSelected = vestingMode === option.value; + const colorClass = option.value === 'vested' + ? 'border-green-500 bg-green-500/10 text-green-600 dark:text-green-400' + : option.value === 'unvested' + ? 'border-orange-500 bg-orange-500/10 text-orange-600 dark:text-orange-400' + : 'border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400'; + + return ( + + ); + })} +
+
+ +
+ + setDestination(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSend()} + className="w-full bg-neutral-100 dark:bg-neutral-900 border border-neutral-200 dark:border-white/10 rounded-xl py-3 px-4 text-neutral-900 dark:text-white focus:border-green-500 outline-none font-mono text-sm" + placeholder="Enter wallet address" + /> +
-
-
- - - Available:{" "} - - {(Number(vestingState.getBalance(selectedAddress)) / 1e8).toFixed(8)} ALPHA - - -
-
- setAmount(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSend()} - className="w-full bg-neutral-100 dark:bg-neutral-900 border border-neutral-200 dark:border-white/10 rounded-xl py-3 px-4 pr-32 text-neutral-900 dark:text-white text-2xl font-mono focus:border-green-500 outline-none" - placeholder="0.00" - /> -
- - - -
-
- {error &&

{error}

} +
+
+ + + Available:{" "} + + {(Number(getCurrentBalance()) / 1e8).toFixed(8)} ALPHA + + +
+
+ setAmount(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSend()} + className="w-full bg-neutral-100 dark:bg-neutral-900 border border-neutral-200 dark:border-white/10 rounded-xl py-3 px-4 pr-32 text-neutral-900 dark:text-white text-2xl font-mono focus:border-green-500 outline-none" + placeholder="0.00" + /> +
+ + +
+
+ {error &&

{error}

} +
diff --git a/src/components/wallet/L1/hooks/index.ts b/src/components/wallet/L1/hooks/index.ts index f30811581..4f5a7fb4d 100644 --- a/src/components/wallet/L1/hooks/index.ts +++ b/src/components/wallet/L1/hooks/index.ts @@ -3,3 +3,5 @@ export { useTransactions } from "./useTransactions"; export { useBalance } from "./useBalance"; export { useL1Wallet, L1_KEYS } from "./useL1Wallet"; export { useAddressNametags } from "./useAddressNametags"; +export { useConnectionStatus } from "./useConnectionStatus"; +export type { ConnectionState, ConnectionStatus } from "./useConnectionStatus"; diff --git a/src/components/wallet/L1/hooks/useAddressNametags.ts b/src/components/wallet/L1/hooks/useAddressNametags.ts index 403ef81b7..4ce908ec2 100644 --- a/src/components/wallet/L1/hooks/useAddressNametags.ts +++ b/src/components/wallet/L1/hooks/useAddressNametags.ts @@ -123,28 +123,79 @@ export function useAddressNametags(addresses: WalletAddress[] | undefined) { if (addr.path) initializedAddressesRef.current.add(addr.path); }); - // Add new addresses to state with loading state - // PATH is the primary key - index and isChange are for display only - const newStates: AddressWithNametag[] = newAddresses.map((addr) => { - return { - address: addr.address, - path: addr.path!, // PATH is the primary key - index: addr.index, // For display only - isChange: addr.isChange, // For display only - ipnsLoading: true, - hasNametag: false, - nametag: undefined, - firstFetchTime: Date.now(), - }; - }); + // Initialize addresses and fetch nametags + const initializeAndFetch = async () => { + // Add new addresses to state - check local storage first before marking as loading + // PATH is the primary key - index and isChange are for display only + const newStates: AddressWithNametag[] = await Promise.all(newAddresses.map(async (addr) => { + // Check local storage first via L3 identity + try { + const identityManager = IdentityManager.getInstance(SESSION_KEY); + const l3Identity = await identityManager.deriveIdentityFromPath(addr.path!); + const localNametag = WalletRepository.checkNametagForAddress(l3Identity.address); + const localTokens = WalletRepository.checkTokensForAddress(l3Identity.address); + + if (localNametag) { + console.log(`🔍 [L1] Found local nametag for ${addr.address.slice(0, 12)}...: ${localNametag.name}`); + return { + address: addr.address, + path: addr.path!, + index: addr.index, + isChange: addr.isChange, + ipnsLoading: false, // No need to fetch - already have it locally + hasNametag: true, + nametag: localNametag.name, + l3Address: l3Identity.address, + hasL3Inventory: true, + firstFetchTime: Date.now(), + }; + } + + // Has tokens but no nametag - still need to check IPNS but mark inventory + if (localTokens) { + return { + address: addr.address, + path: addr.path!, + index: addr.index, + isChange: addr.isChange, + ipnsLoading: true, + hasNametag: false, + nametag: undefined, + l3Address: l3Identity.address, + hasL3Inventory: true, + firstFetchTime: Date.now(), + }; + } + } catch (error) { + console.warn(`[L1] Error checking local nametag for ${addr.address.slice(0, 12)}...`, error); + } + + // Default: need to fetch from IPNS + return { + address: addr.address, + path: addr.path!, + index: addr.index, + isChange: addr.isChange, + ipnsLoading: true, + hasNametag: false, + nametag: undefined, + firstFetchTime: Date.now(), + }; + })); - setAddressesWithNametags(prev => [...prev, ...newStates]); + if (!mountedRef.current) return; - // Fetch nametags using PATH as the identifier - const fetchNewAddresses = async () => { - for (const addr of newAddresses) { + setAddressesWithNametags(prev => [...prev, ...newStates]); + + // Filter addresses that need IPNS fetching (those still loading) + const addressesNeedingFetch = newStates.filter(s => s.ipnsLoading); + + // Fetch nametags using PATH as the identifier + for (const state of addressesNeedingFetch) { if (!mountedRef.current) return; - if (!addr.path) continue; + + const addr = newAddresses.find(a => a.path === state.path); + if (!addr?.path) continue; // Skip if already fetching (use path as key) if (fetchInProgressRef.current.has(addr.path)) continue; @@ -182,7 +233,7 @@ export function useAddressNametags(addresses: WalletAddress[] | undefined) { } }; - fetchNewAddresses(); + initializeAndFetch(); }, [addresses, fetchSingleNametag]); // Continuous polling for addresses without nametags diff --git a/src/components/wallet/L1/hooks/useConnectionStatus.ts b/src/components/wallet/L1/hooks/useConnectionStatus.ts new file mode 100644 index 000000000..781c79395 --- /dev/null +++ b/src/components/wallet/L1/hooks/useConnectionStatus.ts @@ -0,0 +1,115 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { connect, isWebSocketConnected, disconnect } from "../sdk/network"; + +export type ConnectionState = + | "disconnected" + | "connecting" + | "connected" + | "error"; + +export interface ConnectionStatus { + state: ConnectionState; + message: string; + error?: string; +} + +export function useConnectionStatus() { + const [status, setStatus] = useState(() => ({ + state: isWebSocketConnected() ? "connected" : "disconnected", + message: isWebSocketConnected() ? "Connected to Fulcrum" : "Not connected", + })); + + const isMountedRef = useRef(true); + const isConnectingRef = useRef(false); + + const attemptConnect = useCallback(async () => { + if (!isMountedRef.current || isConnectingRef.current) return; + + isConnectingRef.current = true; + + setStatus({ + state: "connecting", + message: "Connecting to Fulcrum server...", + }); + + try { + // network.ts connect() has its own reconnection logic with exponential backoff + // MAX_RECONNECT_ATTEMPTS = 10, BASE_DELAY = 2000ms, MAX_DELAY = 60000ms + await connect(); + + if (!isMountedRef.current) return; + + setStatus({ + state: "connected", + message: "Connected to Fulcrum", + }); + } catch (err) { + if (!isMountedRef.current) return; + + const errorMessage = err instanceof Error ? err.message : "Connection failed"; + + setStatus({ + state: "error", + message: "Connection failed after multiple attempts", + error: errorMessage, + }); + } finally { + isConnectingRef.current = false; + } + }, []); + + const manualConnect = useCallback(() => { + attemptConnect(); + }, [attemptConnect]); + + const cancelConnect = useCallback(() => { + disconnect(); + setStatus({ + state: "disconnected", + message: "Connection cancelled", + }); + }, []); + + // Initial connection on mount + useEffect(() => { + isMountedRef.current = true; + + if (!isWebSocketConnected()) { + attemptConnect(); + } + + return () => { + isMountedRef.current = false; + }; + }, [attemptConnect]); + + // Monitor connection state changes + useEffect(() => { + const checkConnection = () => { + if (!isMountedRef.current) return; + + const connected = isWebSocketConnected(); + + if (connected && status.state !== "connected") { + setStatus({ + state: "connected", + message: "Connected to Fulcrum", + }); + } else if (!connected && status.state === "connected") { + // Connection was lost, start reconnecting + attemptConnect(); + } + }; + + const interval = setInterval(checkConnection, 2000); + return () => clearInterval(interval); + }, [status.state, attemptConnect]); + + return { + ...status, + isConnected: status.state === "connected", + isConnecting: status.state === "connecting", + manualConnect, + cancelConnect, + }; +} diff --git a/src/components/wallet/L1/hooks/useL1Wallet.ts b/src/components/wallet/L1/hooks/useL1Wallet.ts index 3a18cd0dd..4c7d492b9 100644 --- a/src/components/wallet/L1/hooks/useL1Wallet.ts +++ b/src/components/wallet/L1/hooks/useL1Wallet.ts @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useMemo } from "react"; import { importWallet, exportWallet, @@ -26,6 +26,7 @@ import { loadWalletFromUnifiedKeyManager, getUnifiedKeyManager } from "../sdk/un import { UnifiedKeyManager } from "../../shared/services/UnifiedKeyManager"; import { WalletRepository } from "../../../../repositories/WalletRepository"; import { KEYS as L3_KEYS } from "../../L3/hooks/useWallet"; +import { STORAGE_KEYS } from "../../../../config/storageKeys"; // Query keys for L1 wallet export const L1_KEYS = { @@ -38,8 +39,36 @@ export const L1_KEYS = { }; -export function useL1Wallet(selectedAddress?: string) { +export function useL1Wallet(selectedAddressProp?: string) { const queryClient = useQueryClient(); + + // Query: Wallet from UnifiedKeyManager + const walletQuery = useQuery({ + queryKey: L1_KEYS.WALLET, + queryFn: async () => { + const wallet = await loadWalletFromUnifiedKeyManager(); + return wallet; + }, + staleTime: Infinity, // Wallet doesn't change unless we mutate it + }); + + // Compute selected address: use prop if provided, else derive from localStorage path + const selectedAddress = useMemo(() => { + if (selectedAddressProp) return selectedAddressProp; + + const wallet = walletQuery.data; + if (!wallet || wallet.addresses.length === 0) return ""; + + const storedPath = localStorage.getItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_PATH); + if (storedPath) { + const addrFromPath = wallet.addresses.find(a => a.path === storedPath); + if (addrFromPath) return addrFromPath.address; + } + + // Default to first address + return wallet.addresses[0]?.address || ""; + }, [selectedAddressProp, walletQuery.data]); + const selectedAddressRef = useRef(selectedAddress || ""); // Update ref when address changes @@ -93,16 +122,6 @@ export function useL1Wallet(selectedAddress?: string) { }; }, [queryClient]); - // Query: Wallet from UnifiedKeyManager - const walletQuery = useQuery({ - queryKey: L1_KEYS.WALLET, - queryFn: async () => { - const wallet = await loadWalletFromUnifiedKeyManager(); - return wallet; - }, - staleTime: Infinity, // Wallet doesn't change unless we mutate it - }); - // Query: Balance for selected address const balanceQuery = useQuery({ queryKey: L1_KEYS.BALANCE(selectedAddress || ""), @@ -347,14 +366,20 @@ export function useL1Wallet(selectedAddress?: string) { return results; }, onSuccess: () => { - // Invalidate balance and transactions after sending + // Invalidate balance, transactions and vesting after sending if (selectedAddress) { + // Clear vestingState cache first (UTXOs changed) + vestingState.clearAddressCache(selectedAddress); + queryClient.invalidateQueries({ queryKey: L1_KEYS.BALANCE(selectedAddress), }); queryClient.invalidateQueries({ queryKey: L1_KEYS.TRANSACTIONS(selectedAddress), }); + queryClient.invalidateQueries({ + queryKey: L1_KEYS.VESTING(selectedAddress), + }); } }, }); diff --git a/src/components/wallet/L1/modals/L1WalletModal.tsx b/src/components/wallet/L1/modals/L1WalletModal.tsx index 18973f23e..b1571a21c 100644 --- a/src/components/wallet/L1/modals/L1WalletModal.tsx +++ b/src/components/wallet/L1/modals/L1WalletModal.tsx @@ -15,8 +15,6 @@ import { } from "lucide-react"; import { motion, AnimatePresence } from "framer-motion"; import { - connect, - isWebSocketConnected, generateAddress, loadWalletFromStorage, createTransactionPlan, @@ -24,8 +22,9 @@ import { broadcast, type TransactionPlan, } from "../sdk"; -import { useL1Wallet } from "../hooks"; +import { useL1Wallet, useConnectionStatus } from "../hooks"; import { useAddressNametags } from "../hooks/useAddressNametags"; +import { ConnectionStatus } from "../components/ConnectionStatus"; import { WalletRepository } from "../../../../repositories/WalletRepository"; import { STORAGE_KEYS } from "../../../../config/storageKeys"; import { @@ -49,8 +48,10 @@ type ViewMode = "main" | "history"; export function L1WalletModal({ isOpen, onClose, showBalances }: L1WalletModalProps) { const [selectedAddress, setSelectedAddress] = useState(""); const [viewMode, setViewMode] = useState("main"); - const [isConnecting, setIsConnecting] = useState(() => !isWebSocketConnected()); const [txPlan, setTxPlan] = useState(null); + + // Connection status hook + const connection = useConnectionStatus(); const [isSending, setIsSending] = useState(false); const [showDropdown, setShowDropdown] = useState(false); const [copied, setCopied] = useState(false); @@ -84,7 +85,10 @@ export function L1WalletModal({ isOpen, onClose, showBalances }: L1WalletModalPr } = useL1Wallet(selectedAddress); const addresses = wallet?.addresses.map((a) => a.address) ?? []; - const { nametagState } = useAddressNametags(wallet?.addresses); + const { nametagState, addressesWithNametags } = useAddressNametags(wallet?.addresses); + + // Check if any address is still loading nametag from IPNS + const isAnyAddressLoading = addressesWithNametags.some(addr => addr.ipnsLoading); const showMessage = useCallback((type: MessageType, title: string, message: string, txids?: string[]) => { setMessageModal({ show: true, type, title, message, txids }); @@ -94,23 +98,6 @@ export function L1WalletModal({ isOpen, onClose, showBalances }: L1WalletModalPr setMessageModal((prev) => ({ ...prev, show: false })); }, []); - // Connect on modal open - useEffect(() => { - if (!isOpen) return; - if (isWebSocketConnected()) { - setIsConnecting(false); - return; - } - (async () => { - try { - setIsConnecting(true); - await connect(); - } finally { - setIsConnecting(false); - } - })(); - }, [isOpen]); - // Set initial selected address useEffect(() => { if (wallet && wallet.addresses.length > 0) { @@ -133,7 +120,7 @@ export function L1WalletModal({ isOpen, onClose, showBalances }: L1WalletModalPr }, [isOpen]); const onNewAddress = async () => { - if (!wallet) return; + if (!wallet || isAnyAddressLoading) return; try { const addr = generateAddress(wallet); const updated = loadWalletFromStorage("main"); @@ -276,7 +263,15 @@ export function L1WalletModal({ isOpen, onClose, showBalances }: L1WalletModalPr {/* Content */}
- {isConnecting || isLoadingWallet ? ( + {!connection.isConnected ? ( + + ) : isLoadingWallet ? (
@@ -315,16 +310,44 @@ export function L1WalletModal({ isOpen, onClose, showBalances }: L1WalletModalPr {/* Active Address */}
-

Active Address

+

+ {isAnyAddressLoading && } + {isAnyAddressLoading ? 'Checking nametags...' : 'Active Address'} +

@@ -346,6 +369,8 @@ export function L1WalletModal({ isOpen, onClose, showBalances }: L1WalletModalPr {addresses.map(addr => { const nametagInfo = nametagState[addr]; const isSelected = addr === selectedAddress; + const walletAddrInfo = wallet?.addresses.find(a => a.address === addr); + const isChange = walletAddrInfo?.isChange; return ( ); @@ -386,7 +430,9 @@ export function L1WalletModal({ isOpen, onClose, showBalances }: L1WalletModalPr diff --git a/src/components/wallet/L1/sdk/tx.ts b/src/components/wallet/L1/sdk/tx.ts index 374116d4c..ff1e62211 100644 --- a/src/components/wallet/L1/sdk/tx.ts +++ b/src/components/wallet/L1/sdk/tx.ts @@ -308,6 +308,9 @@ export function createAndSignTransaction( /** * Collect UTXOs for required amount * Based on index.html collectUtxosForAmount() + * + * Strategy: First try to find a single UTXO that can cover amount + fee. + * If not found, fall back to combining multiple UTXOs. */ export function collectUtxosForAmount( utxoList: UTXO[], @@ -315,23 +318,55 @@ export function collectUtxosForAmount( recipientAddress: string, senderAddress: string ): TransactionPlan { - // Sort UTXOs by value (ascending) - const sortedUtxos = [...utxoList].sort((a, b) => a.value - b.value); + const totalAvailable = utxoList.reduce((sum, u) => sum + u.value, 0); - const totalAvailable = sortedUtxos.reduce((sum, u) => sum + u.value, 0); - - if (totalAvailable < amountSats) { + if (totalAvailable < amountSats + FEE) { return { success: false, transactions: [], - error: `Insufficient funds. Available: ${totalAvailable / SAT} ALPHA, Required: ${amountSats / SAT} ALPHA`, + error: `Insufficient funds. Available: ${totalAvailable / SAT} ALPHA, Required: ${(amountSats + FEE) / SAT} ALPHA (including fee)`, }; } + // Strategy 1: Find a single UTXO that covers amount + fee + // Sort by value ascending to find the smallest sufficient UTXO + const sortedByValue = [...utxoList].sort((a, b) => a.value - b.value); + const sufficientUtxo = sortedByValue.find(u => u.value >= amountSats + FEE); + + if (sufficientUtxo) { + const changeAmount = sufficientUtxo.value - amountSats - FEE; + const tx: Transaction = { + input: { + txid: sufficientUtxo.txid ?? sufficientUtxo.tx_hash ?? "", + vout: sufficientUtxo.vout ?? sufficientUtxo.tx_pos ?? 0, + value: sufficientUtxo.value, + address: sufficientUtxo.address ?? senderAddress, + }, + outputs: [{ address: recipientAddress, value: amountSats }], + fee: FEE, + changeAmount: changeAmount, + changeAddress: senderAddress, + }; + + // Add change output if above dust + if (changeAmount > DUST) { + tx.outputs.push({ value: changeAmount, address: senderAddress }); + } + + return { + success: true, + transactions: [tx], + }; + } + + // Strategy 2: No single UTXO is sufficient, combine multiple UTXOs + // Sort descending to use larger UTXOs first (fewer transactions) + const sortedDescending = [...utxoList].sort((a, b) => b.value - a.value); + const transactions: Transaction[] = []; let remainingAmount = amountSats; - for (const utxo of sortedUtxos) { + for (const utxo of sortedDescending) { if (remainingAmount <= 0) break; const utxoValue = utxo.value; @@ -339,16 +374,15 @@ export function collectUtxosForAmount( let changeAmount = 0; if (utxoValue >= remainingAmount + FEE) { - // Covers remaining + fee + // This UTXO covers the remaining amount plus fee txAmount = remainingAmount; changeAmount = utxoValue - remainingAmount - FEE; remainingAmount = 0; } else { // Use entire UTXO minus fee txAmount = utxoValue - FEE; + if (txAmount <= 0) continue; // Skip UTXOs too small to cover fee remainingAmount -= txAmount; - - if (txAmount <= 0) continue; } const tx: Transaction = { @@ -408,14 +442,14 @@ export async function createTransactionPlan( const senderAddress = fromAddress || defaultAddr.address; const amountSats = Math.floor(amountAlpha * SAT); - // Check if we have classified UTXOs for vesting mode filtering + // Get UTXOs filtered by current vesting mode (set in SendModal) let utxos: UTXO[]; const currentMode = vestingState.getMode(); if (vestingState.hasClassifiedData(senderAddress)) { - // Use vesting-filtered UTXOs based on current mode + // Use vesting-filtered UTXOs based on selected mode utxos = vestingState.getFilteredUtxos(senderAddress); - console.log(`Using ${utxos.length} vesting-filtered UTXOs (mode: ${currentMode})`); + console.log(`Using ${utxos.length} ${currentMode} UTXOs`); } else { // Fall back to all UTXOs if not yet classified utxos = await getUtxo(senderAddress); @@ -423,8 +457,8 @@ export async function createTransactionPlan( } if (!Array.isArray(utxos) || utxos.length === 0) { - const modeText = currentMode !== 'all' ? ` (${currentMode} mode)` : ''; - throw new Error(`No UTXOs available for address${modeText}: ` + senderAddress); + const modeText = currentMode !== 'all' ? ` (${currentMode} coins)` : ''; + throw new Error(`No UTXOs available${modeText} for address: ` + senderAddress); } return collectUtxosForAmount(utxos, amountSats, toAddress, senderAddress); diff --git a/src/components/wallet/L1/sdk/vestingState.ts b/src/components/wallet/L1/sdk/vestingState.ts index 0dc980b2d..de821df4d 100644 --- a/src/components/wallet/L1/sdk/vestingState.ts +++ b/src/components/wallet/L1/sdk/vestingState.ts @@ -105,6 +105,15 @@ class VestingStateManager { } } + /** + * Get all UTXOs regardless of vesting mode (for transactions) + */ + getAllUtxos(address: string): ClassifiedUTXO[] { + const cache = this.addressCache.get(address); + if (!cache) return []; + return cache.classifiedUtxos.all; + } + /** * Get balance for current vesting mode (in satoshis) */ diff --git a/src/components/wallet/L1/views/L1WalletView.tsx b/src/components/wallet/L1/views/L1WalletView.tsx index ed2609d44..f98474df9 100644 --- a/src/components/wallet/L1/views/L1WalletView.tsx +++ b/src/components/wallet/L1/views/L1WalletView.tsx @@ -1,19 +1,17 @@ import { useEffect, useState, useCallback } from "react"; -import { Loader2 } from "lucide-react"; import { - connect, - isWebSocketConnected, generateAddress, loadWalletFromStorage, createTransactionPlan, createAndSignTransaction, broadcast, - type VestingMode, + vestingState, type TransactionPlan, } from "../sdk"; -import { useL1Wallet } from "../hooks"; +import { useL1Wallet, useConnectionStatus } from "../hooks"; import { HistoryView, MainWalletView } from "."; import { MessageModal, type MessageType } from "../components/modals/MessageModal"; +import { ConnectionStatus } from "../components/ConnectionStatus"; import { WalletRepository } from "../../../../repositories/WalletRepository"; import { UnifiedKeyManager } from "../../shared/services/UnifiedKeyManager"; import { STORAGE_KEYS } from "../../../../config/storageKeys"; @@ -23,7 +21,6 @@ type ViewMode = "main" | "history"; export function L1WalletView({ showBalances }: { showBalances: boolean }) { const [selectedAddress, setSelectedAddress] = useState(""); const [viewMode, setViewMode] = useState("main"); - const [isConnecting, setIsConnecting] = useState(() => !isWebSocketConnected()); const [txPlan, setTxPlan] = useState(null); const [isSending, setIsSending] = useState(false); const [messageModal, setMessageModal] = useState<{ @@ -34,6 +31,9 @@ export function L1WalletView({ showBalances }: { showBalances: boolean }) { txids?: string[]; }>({ show: false, type: "info", title: "", message: "" }); + // Connection status hook + const connection = useConnectionStatus(); + // Use TanStack Query based hook const { wallet, @@ -45,11 +45,12 @@ export function L1WalletView({ showBalances }: { showBalances: boolean }) { isLoadingTransactions, currentBlockHeight, vestingBalances, - isLoadingVesting, deleteWallet, analyzeTransaction, - setVestingMode, invalidateWallet, + invalidateBalance, + invalidateTransactions, + invalidateVesting, } = useL1Wallet(selectedAddress); // Derive addresses from wallet @@ -64,22 +65,6 @@ export function L1WalletView({ showBalances }: { showBalances: boolean }) { setMessageModal((prev) => ({ ...prev, show: false })); }, []); - // Connect on mount (skip if already connected) - useEffect(() => { - if (isWebSocketConnected()) { - setIsConnecting(false); - return; - } - (async () => { - try { - setIsConnecting(true); - await connect(); - } finally { - setIsConnecting(false); - } - })(); - }, []); - // Set initial selected address when wallet loads - sync with L3's stored path useEffect(() => { if (wallet && wallet.addresses.length > 0) { @@ -100,16 +85,6 @@ export function L1WalletView({ showBalances }: { showBalances: boolean }) { } }, [selectedAddress, wallet]); - // Vesting progress for UI - show loading only on initial load, not on refetch - const vestingProgress = isLoadingVesting - ? { current: 0, total: 1 } - : null; - - // Handle vesting mode change - const handleVestingModeChange = useCallback((mode: VestingMode) => { - setVestingMode(mode); - }, [setVestingMode]); - // Delete wallet const onDeleteWallet = async () => { try { @@ -211,6 +186,14 @@ export function L1WalletView({ showBalances }: { showBalances: boolean }) { setTxPlan(null); + // Invalidate queries to refresh data after sending + if (results.length > 0) { + vestingState.clearAddressCache(selectedAddress); + invalidateBalance(); + invalidateTransactions(); + invalidateVesting(); + } + if (errors.length > 0) { if (results.length > 0) { const txids = results.map((r) => r.txid); @@ -267,17 +250,21 @@ export function L1WalletView({ showBalances }: { showBalances: boolean }) { window.location.reload(); }; - // Show loading state while connecting - if (isConnecting || isLoadingWallet) { + // Show connection status while connecting or on error + if (!connection.isConnected) { return ( -
- -
+ ); } // No wallet - return null, WalletGate handles the onboarding flow - if (!wallet) { + if (!wallet || isLoadingWallet) { return null; } @@ -333,8 +320,6 @@ export function L1WalletView({ showBalances }: { showBalances: boolean }) { txPlan={txPlan} isSending={isSending} onConfirmSend={onConfirmSend} - vestingProgress={vestingProgress} - onVestingModeChange={handleVestingModeChange} vestingBalances={vestingBalances} /> Promise; - vestingProgress?: { current: number; total: number } | null; - onVestingModeChange?: (mode: VestingMode) => void; vestingBalances?: VestingBalances; } @@ -115,8 +113,6 @@ export function MainWalletView({ txPlan, isSending, onConfirmSend, - vestingProgress, - onVestingModeChange, vestingBalances, }: MainWalletViewProps) { const [showQR, setShowQR] = useState(false); @@ -407,12 +403,9 @@ export function MainWalletView({ )}
- {/* Vesting Selector */} + {/* Vesting Display */}
- diff --git a/src/components/wallet/L3/views/L3WalletView.tsx b/src/components/wallet/L3/views/L3WalletView.tsx index fe200aec8..f299bca1f 100644 --- a/src/components/wallet/L3/views/L3WalletView.tsx +++ b/src/components/wallet/L3/views/L3WalletView.tsx @@ -49,7 +49,7 @@ export function L3WalletView({ const navigate = useNavigate(); const { identity, assets, tokens, isLoadingAssets, isLoadingIdentity, nametag, getSeedPhrase } = useWallet(); const { exportTxf, importTxf, isExportingTxf, isImportingTxf, isSyncing, isEnabled: isIpfsEnabled } = useIpfsStorage(); - const { totalBalance: l1TotalBalance, deleteWallet } = useL1Wallet(); + const { balance: l1Balance, deleteWallet } = useL1Wallet(); const [activeTab, setActiveTab] = useState('assets'); const [isSendModalOpen, setIsSendModalOpen] = useState(false); @@ -88,7 +88,7 @@ export function L3WalletView({ // Create L1 ALPHA asset const l1AlphaAsset = useMemo(() => { // Convert ALPHA balance to satoshis (8 decimals) - const satoshis = BigInt(Math.round(l1TotalBalance * 100000000)); + const satoshis = BigInt(Math.round(l1Balance * 100000000)); return new AggregatedAsset({ coinId: 'l1-alpha', symbol: 'ALPHA', @@ -101,7 +101,7 @@ export function L3WalletView({ priceEur: 0.92, change24h: 0, }); - }, [l1TotalBalance]); + }, [l1Balance]); const totalValue = useMemo(() => { const l3Value = assets.reduce((sum, asset) => sum + asset.getTotalFiatValue('USD'), 0); @@ -231,7 +231,7 @@ export function L3WalletView({ // Format L1 balance for settings modal const formatL1Balance = (balance: number) => { - return balance.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + return balance.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 4 }); }; if (isLoadingIdentity) { @@ -451,7 +451,7 @@ export function L3WalletView({ transition={{ duration: 0.2 }} className="space-y-2" > - {assets.length === 0 && l1TotalBalance === 0 ? ( + {assets.length === 0 && l1Balance === 0 ? ( ) : ( <> @@ -537,7 +537,7 @@ export function L3WalletView({ onOpenL1Wallet={() => setIsL1WalletOpen(true)} onBackupWallet={() => setIsBackupOpen(true)} onLogout={() => setIsLogoutConfirmOpen(true)} - l1Balance={formatL1Balance(l1TotalBalance)} + l1Balance={formatL1Balance(l1Balance)} /> { - if (!nametag) return; - try { - await navigator.clipboard.writeText(`${nametag}`); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy nametag:', err); - } - }; - return (
@@ -40,7 +29,7 @@ export function WalletPanel() {
{/* TOP BAR: Title & Actions */} -
+
Wallet -
- - {nametag ? `@${nametag}` : 'AgentSphere'} - - {nametag && ( - - {copied ? ( - - ) : ( - - )} - - )} -
+
@@ -117,7 +87,7 @@ export function WalletPanel() {
{/* CONTENT AREA - L3 Only */} -
+
+ +
+ ); + } + return (
diff --git a/src/components/wallet/onboarding/components/AddressSelectionScreen.tsx b/src/components/wallet/onboarding/components/AddressSelectionScreen.tsx index 1f85b4e9e..a4340b6cf 100644 --- a/src/components/wallet/onboarding/components/AddressSelectionScreen.tsx +++ b/src/components/wallet/onboarding/components/AddressSelectionScreen.tsx @@ -32,6 +32,10 @@ export interface DerivedAddressInfo { ipnsName?: string; ipnsLoading?: boolean; ipnsError?: string; + /** L1 balance in ALPHA (for gap limit detection) */ + l1Balance?: number; + /** Whether L1 balance check is in progress */ + balanceLoading?: boolean; } interface AddressSelectionScreenProps { @@ -61,9 +65,9 @@ export function AddressSelectionScreen({ onContinue, onBack, }: AddressSelectionScreenProps) { - // Show addresses that are: checked (IPNS done) OR from L1 wallet (e.g., .dat import) + // Show addresses that are: checked (both IPNS and balance done) OR from L1 wallet const visibleAddresses = derivedAddresses.filter( - (a) => !a.ipnsLoading || a.fromL1Wallet + (a) => (!a.ipnsLoading && !a.balanceLoading) || a.fromL1Wallet ); const selectedAddress = visibleAddresses.find((a) => a.path === selectedAddressPath) || @@ -106,7 +110,7 @@ export function AddressSelectionScreen({
- Checking for nametags... + Checking addresses...
) : ( @@ -127,6 +131,7 @@ export function AddressSelectionScreen({ Change )} + {/* Nametag or loading indicator */} {selectedAddress?.ipnsLoading ? ( ) : selectedAddress?.hasNametag ? ( @@ -182,6 +187,7 @@ export function AddressSelectionScreen({ Change )} + {/* Nametag or loading indicator */} {addr.ipnsLoading ? ( ) : addr.hasNametag ? ( @@ -197,12 +203,12 @@ export function AddressSelectionScreen({ ))}
- {/* Loading indicator while IPNS is checking, or Derive New Address button */} + {/* Loading indicator while checking, or Derive New Address button */} {isCheckingIpns || - derivedAddresses.some((a) => a.ipnsLoading) ? ( + derivedAddresses.some((a) => a.ipnsLoading || a.balanceLoading) ? (
- Checking for nametags... + Checking addresses...
) : ( + + {/* Copy button */} + {displayNametag && ( + + {copied ? ( + + ) : ( + + )} + + )} +
+ + {/* Dropdown */} + + {showDropdown && ( + <> + setShowDropdown(false)} + /> + + {/* Header */} +
+ + {isAnyAddressLoading && ( + + )} + {isAnyAddressLoading ? 'Checking nametags...' : `Addresses (${sortedAddresses.length})`} + + + {isGenerating ? ( + + ) : ( + + )} + New + +
+ + {/* Address list */} +
+ {sortedAddresses.map((addr) => { + const nametagInfo = nametagState[addr.address]; + const isSelected = addr.address === currentAddress?.address; + const isChange = addr.isChange; + + return ( + + ); + })} +
+
+ + )} +
+
+ ); + } + + // Full mode (not used currently, but available for future) + return ( +
+ + + {/* Same dropdown as compact mode */} + + {showDropdown && ( + <> + setShowDropdown(false)} + /> + +
+ + Select Address + + + {isGenerating ? ( + + ) : ( + + )} + New Address + +
+ +
+ {sortedAddresses.map((addr) => { + const nametagInfo = nametagState[addr.address]; + const isSelected = addr.address === currentAddress?.address; + + return ( + + ); + })} +
+
+ + )} +
+
+ ); +} diff --git a/src/components/wallet/shared/components/index.ts b/src/components/wallet/shared/components/index.ts index 79d72e904..905f49b40 100644 --- a/src/components/wallet/shared/components/index.ts +++ b/src/components/wallet/shared/components/index.ts @@ -1,2 +1,3 @@ export { AssetRow } from './AssetRow'; -export { TokenRow } from './TokenRow' +export { TokenRow } from './TokenRow'; +export { AddressSelector } from './AddressSelector'; diff --git a/src/config/activities.ts b/src/config/activities.ts index c1ce42004..30a8579f6 100644 --- a/src/config/activities.ts +++ b/src/config/activities.ts @@ -1,4 +1,4 @@ -import { MessageSquare, Gamepad2, Trophy, ShoppingBag, Shirt, Brain, Sparkles } from 'lucide-react'; +import { MessageSquare, Gamepad2, Trophy, ShoppingBag, Shirt, Brain, Sparkles, Dices, TrendingUp, Banknote, CreditCard, ArrowRightLeft, Cpu, Package, Tag, Coins } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; // Agent types for different UI layouts @@ -54,7 +54,7 @@ export const agents: AgentConfig[] = [ category: 'Assistant', color: 'from-orange-500 to-amber-500', type: 'simple-ai', - greetingMessage: "Hi! I'm Viktor, your personal assistant.\nShort intro on me. I care a great deal about privacy. I don't know you, I don't log you, I don't even know your IP address. You are invisible here and nothing will be recorded about our conversation.\nHow can I help?", + greetingMessage: "Hi! I'm Viktor, your personal assistant.\nI care a great deal about privacy. I don't know you, I don't log you, I don't even know your IP address. You are invisible here and nothing will be recorded about our conversation.\nHow can I help?", backendActivityId: 'ama', quickActions: [ { label: 'Research', message: 'Research the latest news' }, @@ -154,6 +154,106 @@ export const agents: AgentConfig[] = [ contentType: 'merch', hasSidebar: true, }, + { + id: 'casino', + name: 'Agent Casino', + description: 'Verifiably Fair', + Icon: Dices, + category: 'Entertainment', + color: 'from-red-500 to-pink-500', + type: 'simple-ai', + greetingMessage: "Welcome to Agent Casino! Our games are verifiably fair using cryptographic proofs. Ready to try your luck?", + }, + { + id: 'p2p-sports', + name: 'P2P Sports', + description: 'Private Betting', + Icon: Trophy, + category: 'Prediction', + color: 'from-green-500 to-emerald-500', + type: 'simple-ai', + greetingMessage: "Welcome to P2P Sports! Create private betting pools with friends. What sport interests you?", + }, + { + id: 'p2p-derivatives', + name: 'P2P Derivatives', + description: 'Get Leverage', + Icon: TrendingUp, + category: 'Trading', + color: 'from-blue-500 to-indigo-500', + type: 'simple-ai', + greetingMessage: "Welcome to P2P Derivatives! Trade with leverage in a peer-to-peer marketplace. What would you like to trade?", + }, + { + id: 'payday-loans', + name: 'P2P Payday Loans', + description: 'Instant approval', + Icon: Banknote, + category: 'Finance', + color: 'from-lime-500 to-green-500', + type: 'simple-ai', + greetingMessage: "Welcome to P2P Payday Loans! Get instant approval for short-term loans. How can I help you today?", + }, + { + id: 'crypto-offramp', + name: 'P2P Crypto Offramp', + description: 'Convert to cash', + Icon: CreditCard, + category: 'Trading', + color: 'from-cyan-500 to-blue-500', + type: 'simple-ai', + greetingMessage: "Welcome to P2P Crypto Offramp! Convert your crypto to cash easily. What would you like to sell?", + }, + { + id: 'fiat-onramp', + name: 'P2P Fiat Onramp', + description: 'Convert your cash', + Icon: ArrowRightLeft, + category: 'Trading', + color: 'from-violet-500 to-purple-500', + type: 'simple-ai', + greetingMessage: "Welcome to P2P Fiat Onramp! Convert your cash to crypto. What currency do you want to buy?", + }, + { + id: 'friendly-miners', + name: 'Friendly Miners', + description: 'Buy hash rate', + Icon: Cpu, + category: 'Mining', + color: 'from-amber-500 to-orange-500', + type: 'simple-ai', + greetingMessage: "Welcome to Friendly Miners! Purchase hash rate from our network of miners. What are you looking for?", + }, + { + id: 'buy-anything', + name: 'Buy Anything', + description: 'Get product now', + Icon: Package, + category: 'Shopping', + color: 'from-rose-500 to-red-500', + type: 'simple-ai', + greetingMessage: "Welcome to Buy Anything! Tell me what you're looking for and I'll help you find it.", + }, + { + id: 'sell-anything', + name: 'Sell Anything', + description: 'Get a quote', + Icon: Tag, + category: 'Shopping', + color: 'from-teal-500 to-cyan-500', + type: 'simple-ai', + greetingMessage: "Welcome to Sell Anything! Describe what you want to sell and I'll get you a quote.", + }, + { + id: 'get-uct', + name: 'Get UCT', + description: 'Get unicity tokens', + Icon: Coins, + category: 'Tokens', + color: 'from-yellow-400 to-amber-500', + type: 'simple-ai', + greetingMessage: "Welcome! I can help you acquire UCT (Unicity Tokens). How would you like to proceed?", + }, ]; // Get agent by ID diff --git a/src/pages/AgentPage.tsx b/src/pages/AgentPage.tsx index 99ef981b6..340949613 100644 --- a/src/pages/AgentPage.tsx +++ b/src/pages/AgentPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { useParams, Navigate } from 'react-router-dom'; -import { MessageSquare, Wallet } from 'lucide-react'; -import { motion } from 'framer-motion'; +import { MessageSquare, Wallet, ChevronDown, ChevronUp } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; import { AgentCard } from '../components/agents/AgentCard'; import { ChatSection } from '../components/chat/ChatSection'; import { SportChat } from '../components/agents/SportChat'; @@ -13,10 +13,44 @@ import { AIChat } from '../components/agents/AIChat'; import { WalletPanel } from '../components/wallet/WalletPanel'; import { agents, getAgentConfig } from '../config/activities'; +const DEFAULT_VISIBLE_AGENTS = 7; + export function AgentPage() { const { agentId } = useParams<{ agentId: string }>(); const sliderRef = useRef(null); const [activePanel, setActivePanel] = useState<'chat' | 'wallet'>('chat'); + const [showAllAgents, setShowAllAgents] = useState(false); + const [recentAgentIds, setRecentAgentIds] = useState([]); + + const hasMoreAgents = agents.length > DEFAULT_VISIBLE_AGENTS; + + // Track recently selected agents + useEffect(() => { + if (!agentId) return; + + setRecentAgentIds(prev => { + if (prev[0] === agentId) return prev; // Already first, no change + const filtered = prev.filter(id => id !== agentId); + return [agentId, ...filtered].slice(0, DEFAULT_VISIBLE_AGENTS); + }); + }, [agentId]); + + // Calculate visible agents - prioritize recently selected agents + const visibleAgents = (() => { + if (showAllAgents) return agents; + + // Get recent agents that exist in the agents list + const recentAgents = recentAgentIds + .map(id => agents.find(a => a.id === id)) + .filter((a): a is typeof agents[0] => a !== undefined); + + // Get remaining agents (not in recent list) + const remainingAgents = agents.filter(a => !recentAgentIds.includes(a.id)); + + // Combine: recent first, then fill with remaining up to DEFAULT_VISIBLE_AGENTS + const combined = [...recentAgents, ...remainingAgents]; + return combined.slice(0, DEFAULT_VISIBLE_AGENTS); + })(); const currentAgent = agentId ? getAgentConfig(agentId) : undefined; @@ -106,24 +140,71 @@ export function AgentPage() { return (
{/* Desktop agent grid - always visible */} -
+
-
- {agents.map((agent) => ( - + {/* First 7 agents - no animation */} + {visibleAgents.slice(0, DEFAULT_VISIBLE_AGENTS).map((agent) => ( + + ))} + {/* Extra agents - with animation */} + + {showAllAgents && visibleAgents.slice(DEFAULT_VISIBLE_AGENTS).map((agent, index) => ( + + initial={{ opacity: 0, scale: 0.9 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.1 } }} + transition={{ + duration: 0.15, + delay: index * 0.02, + }} + > + + ))} + +
+ + {/* View all / Hide all button */} + {hasMoreAgents && ( +
+
+ )}
{/* Mobile tab switcher with sliding indicator */} diff --git a/tests/unit/components/agents/shared/ChatHistoryIpfsService.test.ts b/tests/unit/components/agents/shared/ChatHistoryIpfsService.test.ts index 560ab17f0..a006ee915 100644 --- a/tests/unit/components/agents/shared/ChatHistoryIpfsService.test.ts +++ b/tests/unit/components/agents/shared/ChatHistoryIpfsService.test.ts @@ -227,14 +227,16 @@ describe("ChatHistoryIpfsService", () => { consoleSpy.mockRestore(); }); - it("should keep tombstones exactly at 30 days", () => { + it("should keep tombstones under 30 days old", () => { const now = Date.now(); - const exactlyThirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000; + // Use 29 days + 23 hours to safely stay under 30 day threshold + // This avoids flaky tests from millisecond timing differences + const justUnderThirtyDaysAgo = now - (30 * 24 * 60 * 60 * 1000 - 60 * 60 * 1000); localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES] = JSON.stringify({ "boundary-session": { sessionId: "boundary-session", - deletedAt: exactlyThirtyDaysAgo, + deletedAt: justUnderThirtyDaysAgo, reason: "user-deleted", }, });