From ecd9ddb4a287a0a0773b3846ca536157324263cb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Jan 2026 00:14:27 +0700 Subject: [PATCH] feat(sidebar): add "View More" button and all agents screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make AgentList a pure display component (remove sorting/slicing logic) - Add listFooterComponent prop to AgentList for extensibility - Add SIDEBAR_AGENT_LIMIT (15) in sidebar, show "View More" when exceeded - Create /agents screen showing full sorted agent list - Update settings page styling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/app/src/app/_layout.tsx | 1 + packages/app/src/app/agents.tsx | 36 + packages/app/src/app/settings.tsx | 621 +++++++++--------- packages/app/src/components/agent-list.tsx | 27 +- .../app/src/components/sliding-sidebar.tsx | 58 +- 5 files changed, 399 insertions(+), 344 deletions(-) create mode 100644 packages/app/src/app/agents.tsx diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 506cd5b4a..d4c88d3ba 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -218,6 +218,7 @@ export default function RootLayout() { }} > + diff --git a/packages/app/src/app/agents.tsx b/packages/app/src/app/agents.tsx new file mode 100644 index 000000000..418226b3a --- /dev/null +++ b/packages/app/src/app/agents.tsx @@ -0,0 +1,36 @@ +import { useMemo } from "react"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { BackHeader } from "@/components/headers/back-header"; +import { AgentList } from "@/components/agent-list"; +import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; + +export default function AgentsScreen() { + const { agents, isRevalidating, refreshAll } = useAggregatedAgents(); + + const sortedAgents = useMemo(() => { + return [...agents].sort((a, b) => { + if (a.requiresAttention && !b.requiresAttention) return -1; + if (!a.requiresAttention && b.requiresAttention) return 1; + return 0; + }); + }, [agents]); + + return ( + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.background, + }, +})); diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx index 892cac23d..773f40311 100644 --- a/packages/app/src/app/settings.tsx +++ b/packages/app/src/app/settings.tsx @@ -49,226 +49,265 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, }, content: { - padding: theme.spacing[6], + padding: theme.spacing[4], + paddingTop: theme.spacing[6], }, section: { - marginBottom: theme.spacing[8], + marginBottom: theme.spacing[6], }, sectionTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.lg, + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.semibold, - marginBottom: theme.spacing[4], + letterSpacing: 0.6, + textTransform: "uppercase", + marginBottom: theme.spacing[3], + marginLeft: theme.spacing[1], }, label: { color: theme.colors.mutedForeground, - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.semibold, + letterSpacing: 0.4, + textTransform: "uppercase", marginBottom: theme.spacing[2], }, input: { - backgroundColor: theme.colors.card, + backgroundColor: theme.colors.background, color: theme.colors.foreground, - padding: theme.spacing[4], - borderRadius: theme.borderRadius.lg, - marginBottom: theme.spacing[2], + padding: theme.spacing[3], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + fontSize: theme.fontSize.base, }, - settingCard: { + inputUrl: { + fontFamily: "monospace", + }, + // Host card styles + hostCard: { backgroundColor: theme.colors.card, borderRadius: theme.borderRadius.lg, - padding: theme.spacing[4], + borderWidth: 1, + borderColor: theme.colors.border, marginBottom: theme.spacing[3], + overflow: "hidden", }, - daemonCard: { + hostCardContent: { + padding: theme.spacing[4], gap: theme.spacing[2], }, - settingRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - daemonHeaderRow: { + hostHeaderRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", }, - settingContent: { - flex: 1, - }, - settingTitle: { + hostLabel: { color: theme.colors.foreground, fontSize: theme.fontSize.base, - marginBottom: theme.spacing[1], + fontWeight: theme.fontWeight.semibold, }, - settingDescription: { + hostUrl: { color: theme.colors.mutedForeground, fontSize: theme.fontSize.sm, + fontFamily: "monospace", }, - connectionStatusBadge: { + hostError: { + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.xs, + }, + // Status pill + statusPill: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[1], - }, - connectionStatusDot: { - width: 8, - height: 8, + gap: 6, + paddingHorizontal: theme.spacing[2], + paddingVertical: 4, borderRadius: theme.borderRadius.full, }, - connectionStatusText: { + statusDot: { + width: 6, + height: 6, + borderRadius: theme.borderRadius.full, + }, + statusText: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.semibold, }, - connectionErrorText: { - color: theme.colors.destructive, - fontSize: theme.fontSize.xs, - marginTop: theme.spacing[1], - }, - daemonActionsRow: { + // Host actions + hostActionsRow: { flexDirection: "row", - flexWrap: "wrap", - gap: theme.spacing[2], + alignItems: "center", + borderTopWidth: 1, + borderTopColor: theme.colors.border, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[4], + gap: theme.spacing[1], }, - daemonActionButton: { + hostActionButton: { paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.md, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, }, - daemonActionText: { - color: theme.colors.foreground, + hostActionText: { + color: theme.colors.mutedForeground, fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.semibold, }, - daemonActionDestructive: { - borderColor: theme.colors.destructive, + hostActionPrimary: { + backgroundColor: theme.colors.palette.blue[500], }, - daemonActionDestructiveText: { - color: theme.colors.destructive, + hostActionPrimaryText: { + color: theme.colors.palette.white, }, - daemonFormActionsRow: { + hostActionDestructiveText: { + color: theme.colors.palette.red[500], + }, + hostActionDisabled: { + opacity: theme.opacity[50], + }, + hostActionSeparator: { + width: 1, + height: 16, + backgroundColor: theme.colors.border, + marginHorizontal: theme.spacing[1], + }, + testResultText: { + fontSize: theme.fontSize.xs, + }, + // Add host button + addButton: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[2], + paddingVertical: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + borderStyle: "dashed", + }, + addButtonText: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, + // Add/Edit form + formCard: { + backgroundColor: theme.colors.card, + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + padding: theme.spacing[4], + marginBottom: theme.spacing[3], + gap: theme.spacing[4], + }, + formTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.semibold, + }, + formField: { + gap: theme.spacing[2], + }, + formActionsRow: { flexDirection: "row", justifyContent: "flex-end", gap: theme.spacing[2], - marginTop: theme.spacing[3], }, - daemonActionPrimary: { + formButton: { + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[4], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + }, + formButtonPrimary: { backgroundColor: theme.colors.palette.blue[500], borderColor: theme.colors.palette.blue[500], }, - daemonActionPrimaryText: { - color: theme.colors.palette.white, - }, - daemonActionDisabled: { - opacity: theme.opacity[50], - }, - testResultSuccessText: { - color: theme.colors.palette.green[400], - fontSize: theme.fontSize.xs, - }, - testResultErrorText: { - color: theme.colors.palette.red[200], - fontSize: theme.fontSize.xs, - }, - testResultInfoText: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.xs, - }, - addButton: { - paddingVertical: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - alignItems: "center", - }, - addButtonText: { + formButtonText: { color: theme.colors.foreground, + fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.semibold, }, - themeCardDisabled: { + formButtonPrimaryText: { + color: theme.colors.palette.white, + }, + // Audio settings card + audioCard: { backgroundColor: theme.colors.card, borderRadius: theme.borderRadius.lg, - padding: theme.spacing[4], - opacity: theme.opacity[50], + borderWidth: 1, + borderColor: theme.colors.border, + overflow: "hidden", }, - themeHelpText: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.sm, - marginBottom: theme.spacing[3], - }, - themeOption: { + audioRow: { flexDirection: "row", alignItems: "center", - paddingVertical: theme.spacing[2], + justifyContent: "space-between", + paddingVertical: theme.spacing[4], + paddingHorizontal: theme.spacing[4], }, - radioOuter: { - width: 20, - height: 20, - borderRadius: theme.borderRadius.full, - borderWidth: theme.borderWidth[2], + audioRowBorder: { + borderTopWidth: 1, + borderTopColor: theme.colors.border, + }, + audioRowContent: { + flex: 1, marginRight: theme.spacing[3], - alignItems: "center", - justifyContent: "center", }, - radioOuterSelected: { - borderColor: theme.colors.palette.blue[500], + audioRowTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, }, - radioOuterUnselected: { - borderColor: theme.colors.border, - }, - radioInner: { - width: 12, - height: 12, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.palette.blue[500], - }, - themeOptionText: { + audioRowDescription: { color: theme.colors.mutedForeground, - fontSize: theme.fontSize.base, - textTransform: "capitalize", - }, - saveButton: { - padding: theme.spacing[4], - borderRadius: theme.borderRadius.lg, - marginBottom: theme.spacing[3], - backgroundColor: theme.colors.palette.blue[500], - }, - saveButtonDisabled: { - backgroundColor: theme.colors.palette.blue[900], - opacity: theme.opacity[50], - }, - saveButtonText: { - color: theme.colors.palette.white, - textAlign: "center", - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, - }, - resetButton: { - padding: theme.spacing[4], - borderRadius: theme.borderRadius.lg, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.destructive, - }, - resetButtonText: { - color: theme.colors.destructive, - textAlign: "center", - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, + fontSize: theme.fontSize.sm, + marginTop: 2, }, + // Footer footer: { - borderTopWidth: theme.borderWidth[1], + borderTopWidth: 1, borderTopColor: theme.colors.border, paddingTop: theme.spacing[6], + paddingBottom: theme.spacing[4], + alignItems: "center", + gap: theme.spacing[3], + }, + footerAppInfo: { + alignItems: "center", + gap: theme.spacing[1], }, footerText: { color: theme.colors.mutedForeground, fontSize: theme.fontSize.sm, - textAlign: "center", }, footerVersion: { color: theme.colors.mutedForeground, fontSize: theme.fontSize.xs, + }, + resetButton: { + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + }, + resetButtonText: { + color: theme.colors.palette.red[500], + fontSize: theme.fontSize.sm, + }, + // Empty state + emptyCard: { + backgroundColor: theme.colors.card, + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + padding: theme.spacing[4], + marginBottom: theme.spacing[3], + }, + emptyText: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.sm, textAlign: "center", - marginTop: theme.spacing[1], }, })); @@ -281,10 +320,6 @@ export default function SettingsScreen() { const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings(); const { daemons, isLoading: daemonLoading, addDaemon, updateDaemon, removeDaemon } = useDaemonRegistry(); const { connectionStates, updateConnectionStatus } = useDaemonConnections(); - const [useSpeaker, setUseSpeaker] = useState(settings.useSpeaker); - const [keepScreenOn, setKeepScreenOn] = useState(settings.keepScreenOn); - const [theme, setTheme] = useState<"dark" | "light" | "auto">(settings.theme); - const [hasChanges, setHasChanges] = useState(false); const [isDaemonFormVisible, setIsDaemonFormVisible] = useState(false); const [daemonForm, setDaemonForm] = useState<{ id: string | null; label: string; wsUrl: string }>({ id: null, label: "", wsUrl: "" }); const [isSavingDaemon, setIsSavingDaemon] = useState(false); @@ -484,21 +519,19 @@ export default function SettingsScreen() { [testServerConnection, updateConnectionStatus, updateDaemonTestState] ); - // Update local state when settings load - useEffect(() => { - setUseSpeaker(settings.useSpeaker); - setKeepScreenOn(settings.keepScreenOn); - setTheme(settings.theme); - }, [settings]); + const handleToggleUseSpeaker = useCallback( + (value: boolean) => { + void updateSettings({ ...settings, useSpeaker: value }); + }, + [settings, updateSettings] + ); - // Track changes - useEffect(() => { - const changed = - useSpeaker !== settings.useSpeaker || - keepScreenOn !== settings.keepScreenOn || - theme !== settings.theme; - setHasChanges(changed); - }, [useSpeaker, keepScreenOn, theme, settings]); + const handleToggleKeepScreenOn = useCallback( + (value: boolean) => { + void updateSettings({ ...settings, keepScreenOn: value }); + }, + [settings, updateSettings] + ); function validateServerUrl(url: string): boolean { try { @@ -509,32 +542,7 @@ export default function SettingsScreen() { } } - async function handleSave() { - try { - await updateSettings({ - useSpeaker, - keepScreenOn, - theme, - }); - - Alert.alert( - "Settings Saved", - "Your settings have been saved successfully.", - [ - { - text: "OK", - onPress: () => router.back(), - }, - ] - ); - } catch (error) { - Alert.alert("Error", "Failed to save settings. Please try again.", [ - { text: "OK" }, - ]); - } - } - - async function handleReset() { + function handleReset() { Alert.alert( "Reset Settings", "Are you sure you want to reset all settings to defaults?", @@ -584,8 +592,8 @@ export default function SettingsScreen() { Hosts {daemons.length === 0 ? ( - - No hosts configured. + + No hosts configured ) : ( daemons.map((daemon) => { @@ -613,46 +621,49 @@ export default function SettingsScreen() { )} {isDaemonFormVisible ? ( - - {daemonForm.id ? "Edit Host" : "Add Host"} - Label - setDaemonForm((prev) => ({ ...prev, label: text }))} - placeholder="My Host" - placeholderTextColor={defaultTheme.colors.mutedForeground} - /> - - WebSocket URL - setDaemonForm((prev) => ({ ...prev, wsUrl: text }))} - placeholder="wss://example.com/ws" - placeholderTextColor={defaultTheme.colors.mutedForeground} - autoCapitalize="none" - autoCorrect={false} - keyboardType="url" - /> - - - Cancel + + {daemonForm.id ? "Edit Host" : "Add Host"} + + Label + setDaemonForm((prev) => ({ ...prev, label: text }))} + placeholder="My Host" + placeholderTextColor={defaultTheme.colors.mutedForeground} + /> + + + WebSocket URL + setDaemonForm((prev) => ({ ...prev, wsUrl: text }))} + placeholder="wss://example.com/ws" + placeholderTextColor={defaultTheme.colors.mutedForeground} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + /> + + + + Cancel - - {isSavingDaemon ? "Saving..." : daemonForm.id ? "Save Host" : "Add Host"} + + {isSavingDaemon ? "Saving..." : daemonForm.id ? "Save" : "Add"} ) : ( handleOpenDaemonForm()}> - Add Host + + Add Host )} @@ -661,96 +672,48 @@ export default function SettingsScreen() { Audio - - - - Use Speaker - + + + + Use Speaker + Play audio through speaker instead of earpiece - - - - - Keep Screen On - + + + Keep Screen On + Prevent screen from sleeping during voice sessions - {/* Theme Settings */} - - Theme - - - - Theme selection (coming soon) - - - {(["dark", "light", "auto"] as const).map((themeOption) => ( - - - {theme === themeOption && ( - - )} - - {themeOption} - - ))} - - - - {/* Action Buttons */} - - - Save Settings - - - - Reset to Defaults - - - - {/* App Info */} + {/* Footer */} - Voice Assistant Mobile - Version 1.0.0 + + Voice Dev Mobile + Version 1.0.0 + + + Reset to defaults + @@ -921,67 +884,73 @@ function DaemonCard({ ]); }, [beginServerRestart, daemon.label, restartConfirmationMessage, restartServerFn]); + // Status pill background with 10% opacity + const statusPillBg = + statusTone === "success" + ? "rgba(74, 222, 128, 0.1)" + : statusTone === "warning" + ? "rgba(245, 158, 11, 0.1)" + : statusTone === "error" + ? "rgba(248, 113, 113, 0.1)" + : "rgba(161, 161, 170, 0.1)"; + + const testResultColor = + testState?.status === "success" + ? theme.colors.palette.green[400] + : testState?.status === "error" + ? theme.colors.palette.red[300] + : theme.colors.mutedForeground; + return ( - - - {daemon.label} - - - {badgeText} + + + + {daemon.label} + + + {badgeText} + + {daemon.wsUrl} + {connectionError ? {connectionError} : null} + {testState && testState.status !== "idle" ? ( + + {testState.message ?? (testState.status === "success" ? "Reachable" : "Testing...")} + + ) : null} - {daemon.wsUrl} - {connectionError ? {connectionError} : null} - {testState && testState.status !== "idle" ? ( - - {testState.message ?? (testState.status === "success" ? "Reachable" : "Testing...")} - - ) : null} - + onTestConnection(daemon)} disabled={isTesting} > - + {isTesting ? "Testing..." : "Test"} + {isRestarting ? ( - + ) : ( - Restart + Restart )} - onEdit(daemon)}> - Edit + onEdit(daemon)}> + Edit - onRemove(daemon)} - > - Remove + + onRemove(daemon)}> + Remove diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 2ad5085e2..04cd621c5 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -1,6 +1,6 @@ import { View, Text, Pressable, Modal, RefreshControl, type ListRenderItem } from "react-native"; import { FlatList } from "react-native-gesture-handler"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useState, type ReactElement } from "react"; import { router, usePathname } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { formatTimeAgo } from "@/utils/time"; @@ -14,25 +14,21 @@ interface AgentListProps { onRefresh?: () => void; selectedAgentId?: string; onAgentSelect?: () => void; + listFooterComponent?: ReactElement | null; } -export function AgentList({ agents, isRefreshing = false, onRefresh, selectedAgentId, onAgentSelect }: AgentListProps) { +export function AgentList({ + agents, + isRefreshing = false, + onRefresh, + selectedAgentId, + onAgentSelect, + listFooterComponent, +}: AgentListProps) { const { theme } = useUnistyles(); const pathname = usePathname(); const [actionAgent, setActionAgent] = useState(null); - // Sort agents with requires attention at the top, limit to 15 for fast rendering - const sortedAgents = useMemo(() => { - return [...agents] - .sort((a, b) => { - // Requires attention first - if (a.requiresAttention && !b.requiresAttention) return -1; - if (!a.requiresAttention && b.requiresAttention) return 1; - return 0; - }) - .slice(0, 15); - }, [agents]); - // Get the methods for the specific server const methods = useSessionStore((state) => actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.methods : undefined @@ -148,7 +144,7 @@ export function AgentList({ agents, isRefreshing = false, onRefresh, selectedAge return ( <> { + return [...agents].sort((a, b) => { + if (a.requiresAttention && !b.requiresAttention) return -1; + if (!a.requiresAttention && b.requiresAttention) return 1; + return 0; + }); + }, [agents]); + + const limitedAgents = useMemo( + () => sortedAgents.slice(0, SIDEBAR_AGENT_LIMIT), + [sortedAgents] + ); + const hasMore = agents.length > SIDEBAR_AGENT_LIMIT; + const handleClose = useCallback(() => { close(); }, [close]); @@ -74,6 +89,15 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { close(); }, [close, translateX, backdropOpacity, windowWidth]); + const handleViewMore = useCallback(() => { + if (isMobile) { + translateX.value = -windowWidth; + backdropOpacity.value = 0; + } + close(); + router.push("/agents"); + }, [backdropOpacity, close, isMobile, translateX, windowWidth]); + // Close gesture (swipe left to close when sidebar is open) const closeGesture = Gesture.Pan() .enabled(isOpen) @@ -121,6 +145,21 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", })); + const viewMoreButton = hasMore ? ( + + [ + styles.newAgentButton, + styles.viewMoreButton, + hovered && styles.newAgentButtonHovered, + ]} + onPress={handleViewMore} + > + View More + + + ) : null; + // Render mobile sidebar // On web, use "auto" instead of "box-none" because web's pointer-events: none blocks scroll const overlayPointerEvents = Platform.OS === "web" ? "auto" : "box-none"; @@ -158,11 +197,12 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { {/* Middle: scrollable agent list */} {/* Footer */} @@ -205,10 +245,11 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { {/* Middle: scrollable agent list */} {/* Footer: Settings button */} @@ -277,6 +318,17 @@ const styles = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.normal, color: theme.colors.foreground, }, + viewMoreContainer: { + paddingTop: theme.spacing[2], + }, + viewMoreButton: { + paddingVertical: theme.spacing[2], + }, + viewMoreButtonText: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foreground, + }, sidebarFooter: { paddingHorizontal: theme.spacing[4], paddingTop: theme.spacing[2],