feat(sidebar): add "View More" button and all agents screen

- 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 <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2026-01-06 00:14:27 +07:00
co-authored by Claude Opus 4.5
parent 0806fd7965
commit ecd9ddb4a2
5 changed files with 399 additions and 344 deletions
+1
View File
@@ -218,6 +218,7 @@ export default function RootLayout() {
}} }}
> >
<Stack.Screen name="index" /> <Stack.Screen name="index" />
<Stack.Screen name="agents" />
<Stack.Screen name="orchestrator" /> <Stack.Screen name="orchestrator" />
<Stack.Screen name="agent/[id]" /> <Stack.Screen name="agent/[id]" />
<Stack.Screen name="agent/[serverId]/[agentId]" /> <Stack.Screen name="agent/[serverId]/[agentId]" />
+36
View File
@@ -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 (
<View style={styles.container}>
<BackHeader title="All Agents" />
<AgentList
agents={sortedAgents}
isRefreshing={isRevalidating}
onRefresh={refreshAll}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
}));
+295 -326
View File
@@ -49,226 +49,265 @@ const styles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
}, },
content: { content: {
padding: theme.spacing[6], padding: theme.spacing[4],
paddingTop: theme.spacing[6],
}, },
section: { section: {
marginBottom: theme.spacing[8], marginBottom: theme.spacing[6],
}, },
sectionTitle: { sectionTitle: {
color: theme.colors.foreground, color: theme.colors.mutedForeground,
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[4], letterSpacing: 0.6,
textTransform: "uppercase",
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
}, },
label: { label: {
color: theme.colors.mutedForeground, 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], marginBottom: theme.spacing[2],
}, },
input: { input: {
backgroundColor: theme.colors.card, backgroundColor: theme.colors.background,
color: theme.colors.foreground, color: theme.colors.foreground,
padding: theme.spacing[4], padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.md,
marginBottom: theme.spacing[2], borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
}, },
settingCard: { inputUrl: {
fontFamily: "monospace",
},
// Host card styles
hostCard: {
backgroundColor: theme.colors.card, backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
padding: theme.spacing[4], borderWidth: 1,
borderColor: theme.colors.border,
marginBottom: theme.spacing[3], marginBottom: theme.spacing[3],
overflow: "hidden",
}, },
daemonCard: { hostCardContent: {
padding: theme.spacing[4],
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
settingRow: { hostHeaderRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
daemonHeaderRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
}, },
settingContent: { hostLabel: {
flex: 1,
},
settingTitle: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
marginBottom: theme.spacing[1], fontWeight: theme.fontWeight.semibold,
}, },
settingDescription: { hostUrl: {
color: theme.colors.mutedForeground, color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontFamily: "monospace",
}, },
connectionStatusBadge: { hostError: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
},
// Status pill
statusPill: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1], gap: 6,
}, paddingHorizontal: theme.spacing[2],
connectionStatusDot: { paddingVertical: 4,
width: 8,
height: 8,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
}, },
connectionStatusText: { statusDot: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
},
statusText: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
}, },
connectionErrorText: { // Host actions
color: theme.colors.destructive, hostActionsRow: {
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
daemonActionsRow: {
flexDirection: "row", flexDirection: "row",
flexWrap: "wrap", alignItems: "center",
gap: theme.spacing[2], borderTopWidth: 1,
borderTopColor: theme.colors.border,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
gap: theme.spacing[1],
}, },
daemonActionButton: { hostActionButton: {
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
}, },
daemonActionText: { hostActionText: {
color: theme.colors.foreground, color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
}, },
daemonActionDestructive: { hostActionPrimary: {
borderColor: theme.colors.destructive, backgroundColor: theme.colors.palette.blue[500],
}, },
daemonActionDestructiveText: { hostActionPrimaryText: {
color: theme.colors.destructive, 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", flexDirection: "row",
justifyContent: "flex-end", justifyContent: "flex-end",
gap: theme.spacing[2], 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], backgroundColor: theme.colors.palette.blue[500],
borderColor: theme.colors.palette.blue[500], borderColor: theme.colors.palette.blue[500],
}, },
daemonActionPrimaryText: { formButtonText: {
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: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
}, },
themeCardDisabled: { formButtonPrimaryText: {
color: theme.colors.palette.white,
},
// Audio settings card
audioCard: {
backgroundColor: theme.colors.card, backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
padding: theme.spacing[4], borderWidth: 1,
opacity: theme.opacity[50], borderColor: theme.colors.border,
overflow: "hidden",
}, },
themeHelpText: { audioRow: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
marginBottom: theme.spacing[3],
},
themeOption: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
paddingVertical: theme.spacing[2], justifyContent: "space-between",
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
}, },
radioOuter: { audioRowBorder: {
width: 20, borderTopWidth: 1,
height: 20, borderTopColor: theme.colors.border,
borderRadius: theme.borderRadius.full, },
borderWidth: theme.borderWidth[2], audioRowContent: {
flex: 1,
marginRight: theme.spacing[3], marginRight: theme.spacing[3],
alignItems: "center",
justifyContent: "center",
}, },
radioOuterSelected: { audioRowTitle: {
borderColor: theme.colors.palette.blue[500], color: theme.colors.foreground,
fontSize: theme.fontSize.base,
}, },
radioOuterUnselected: { audioRowDescription: {
borderColor: theme.colors.border,
},
radioInner: {
width: 12,
height: 12,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.blue[500],
},
themeOptionText: {
color: theme.colors.mutedForeground, color: theme.colors.mutedForeground,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.sm,
textTransform: "capitalize", marginTop: 2,
},
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,
}, },
// Footer
footer: { footer: {
borderTopWidth: theme.borderWidth[1], borderTopWidth: 1,
borderTopColor: theme.colors.border, borderTopColor: theme.colors.border,
paddingTop: theme.spacing[6], paddingTop: theme.spacing[6],
paddingBottom: theme.spacing[4],
alignItems: "center",
gap: theme.spacing[3],
},
footerAppInfo: {
alignItems: "center",
gap: theme.spacing[1],
}, },
footerText: { footerText: {
color: theme.colors.mutedForeground, color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
textAlign: "center",
}, },
footerVersion: { footerVersion: {
color: theme.colors.mutedForeground, color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs, 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", textAlign: "center",
marginTop: theme.spacing[1],
}, },
})); }));
@@ -281,10 +320,6 @@ export default function SettingsScreen() {
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings(); const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings();
const { daemons, isLoading: daemonLoading, addDaemon, updateDaemon, removeDaemon } = useDaemonRegistry(); const { daemons, isLoading: daemonLoading, addDaemon, updateDaemon, removeDaemon } = useDaemonRegistry();
const { connectionStates, updateConnectionStatus } = useDaemonConnections(); 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 [isDaemonFormVisible, setIsDaemonFormVisible] = useState(false);
const [daemonForm, setDaemonForm] = useState<{ id: string | null; label: string; wsUrl: string }>({ id: null, label: "", wsUrl: "" }); const [daemonForm, setDaemonForm] = useState<{ id: string | null; label: string; wsUrl: string }>({ id: null, label: "", wsUrl: "" });
const [isSavingDaemon, setIsSavingDaemon] = useState(false); const [isSavingDaemon, setIsSavingDaemon] = useState(false);
@@ -484,21 +519,19 @@ export default function SettingsScreen() {
[testServerConnection, updateConnectionStatus, updateDaemonTestState] [testServerConnection, updateConnectionStatus, updateDaemonTestState]
); );
// Update local state when settings load const handleToggleUseSpeaker = useCallback(
useEffect(() => { (value: boolean) => {
setUseSpeaker(settings.useSpeaker); void updateSettings({ ...settings, useSpeaker: value });
setKeepScreenOn(settings.keepScreenOn); },
setTheme(settings.theme); [settings, updateSettings]
}, [settings]); );
// Track changes const handleToggleKeepScreenOn = useCallback(
useEffect(() => { (value: boolean) => {
const changed = void updateSettings({ ...settings, keepScreenOn: value });
useSpeaker !== settings.useSpeaker || },
keepScreenOn !== settings.keepScreenOn || [settings, updateSettings]
theme !== settings.theme; );
setHasChanges(changed);
}, [useSpeaker, keepScreenOn, theme, settings]);
function validateServerUrl(url: string): boolean { function validateServerUrl(url: string): boolean {
try { try {
@@ -509,32 +542,7 @@ export default function SettingsScreen() {
} }
} }
async function handleSave() { function handleReset() {
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() {
Alert.alert( Alert.alert(
"Reset Settings", "Reset Settings",
"Are you sure you want to reset all settings to defaults?", "Are you sure you want to reset all settings to defaults?",
@@ -584,8 +592,8 @@ export default function SettingsScreen() {
<Text style={styles.sectionTitle}>Hosts</Text> <Text style={styles.sectionTitle}>Hosts</Text>
{daemons.length === 0 ? ( {daemons.length === 0 ? (
<View style={styles.settingCard}> <View style={styles.emptyCard}>
<Text style={styles.settingDescription}>No hosts configured.</Text> <Text style={styles.emptyText}>No hosts configured</Text>
</View> </View>
) : ( ) : (
daemons.map((daemon) => { daemons.map((daemon) => {
@@ -613,46 +621,49 @@ export default function SettingsScreen() {
)} )}
{isDaemonFormVisible ? ( {isDaemonFormVisible ? (
<View style={styles.settingCard}> <View style={styles.formCard}>
<Text style={styles.settingTitle}>{daemonForm.id ? "Edit Host" : "Add Host"}</Text> <Text style={styles.formTitle}>{daemonForm.id ? "Edit Host" : "Add Host"}</Text>
<Text style={styles.label}>Label</Text> <View style={styles.formField}>
<TextInput <Text style={styles.label}>Label</Text>
style={styles.input} <TextInput
value={daemonForm.label} style={styles.input}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, label: text }))} value={daemonForm.label}
placeholder="My Host" onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, label: text }))}
placeholderTextColor={defaultTheme.colors.mutedForeground} placeholder="My Host"
/> placeholderTextColor={defaultTheme.colors.mutedForeground}
/>
<Text style={styles.label}>WebSocket URL</Text> </View>
<TextInput <View style={styles.formField}>
style={styles.input} <Text style={styles.label}>WebSocket URL</Text>
value={daemonForm.wsUrl} <TextInput
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, wsUrl: text }))} style={[styles.input, styles.inputUrl]}
placeholder="wss://example.com/ws" value={daemonForm.wsUrl}
placeholderTextColor={defaultTheme.colors.mutedForeground} onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, wsUrl: text }))}
autoCapitalize="none" placeholder="wss://example.com/ws"
autoCorrect={false} placeholderTextColor={defaultTheme.colors.mutedForeground}
keyboardType="url" autoCapitalize="none"
/> autoCorrect={false}
<View style={styles.daemonFormActionsRow}> keyboardType="url"
<Pressable style={[styles.daemonActionButton, styles.daemonActionDestructive]} onPress={handleCloseDaemonForm}> />
<Text style={[styles.daemonActionText, styles.daemonActionDestructiveText]}>Cancel</Text> </View>
<View style={styles.formActionsRow}>
<Pressable style={styles.formButton} onPress={handleCloseDaemonForm}>
<Text style={styles.formButtonText}>Cancel</Text>
</Pressable> </Pressable>
<Pressable <Pressable
style={[styles.daemonActionButton, styles.daemonActionPrimary, isSavingDaemon && styles.daemonActionDisabled]} style={[styles.formButton, styles.formButtonPrimary, isSavingDaemon && styles.hostActionDisabled]}
onPress={handleSubmitDaemonForm} onPress={handleSubmitDaemonForm}
disabled={isSavingDaemon} disabled={isSavingDaemon}
> >
<Text style={[styles.daemonActionText, styles.daemonActionPrimaryText]}> <Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>
{isSavingDaemon ? "Saving..." : daemonForm.id ? "Save Host" : "Add Host"} {isSavingDaemon ? "Saving..." : daemonForm.id ? "Save" : "Add"}
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>
) : ( ) : (
<Pressable style={styles.addButton} onPress={() => handleOpenDaemonForm()}> <Pressable style={styles.addButton} onPress={() => handleOpenDaemonForm()}>
<Text style={styles.addButtonText}>Add Host</Text> <Text style={styles.addButtonText}>+ Add Host</Text>
</Pressable> </Pressable>
)} )}
</View> </View>
@@ -661,96 +672,48 @@ export default function SettingsScreen() {
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Audio</Text> <Text style={styles.sectionTitle}>Audio</Text>
<View style={styles.settingCard}> <View style={styles.audioCard}>
<View style={styles.settingRow}> <View style={styles.audioRow}>
<View style={styles.settingContent}> <View style={styles.audioRowContent}>
<Text style={styles.settingTitle}>Use Speaker</Text> <Text style={styles.audioRowTitle}>Use Speaker</Text>
<Text style={styles.settingDescription}> <Text style={styles.audioRowDescription}>
Play audio through speaker instead of earpiece Play audio through speaker instead of earpiece
</Text> </Text>
</View> </View>
<Switch <Switch
value={useSpeaker} value={settings.useSpeaker}
onValueChange={setUseSpeaker} onValueChange={handleToggleUseSpeaker}
trackColor={{ false: defaultTheme.colors.palette.gray[700], true: defaultTheme.colors.palette.blue[500] }} trackColor={{ false: defaultTheme.colors.palette.gray[700], true: defaultTheme.colors.palette.blue[500] }}
thumbColor={useSpeaker ? defaultTheme.colors.palette.blue[400] : defaultTheme.colors.palette.gray[300]} thumbColor={settings.useSpeaker ? defaultTheme.colors.palette.white : defaultTheme.colors.palette.gray[300]}
/> />
</View> </View>
</View>
<View style={styles.settingCard}> <View style={[styles.audioRow, styles.audioRowBorder]}>
<View style={styles.settingRow}> <View style={styles.audioRowContent}>
<View style={styles.settingContent}> <Text style={styles.audioRowTitle}>Keep Screen On</Text>
<Text style={styles.settingTitle}>Keep Screen On</Text> <Text style={styles.audioRowDescription}>
<Text style={styles.settingDescription}>
Prevent screen from sleeping during voice sessions Prevent screen from sleeping during voice sessions
</Text> </Text>
</View> </View>
<Switch <Switch
value={keepScreenOn} value={settings.keepScreenOn}
onValueChange={setKeepScreenOn} onValueChange={handleToggleKeepScreenOn}
trackColor={{ false: defaultTheme.colors.palette.gray[700], true: defaultTheme.colors.palette.blue[500] }} trackColor={{ false: defaultTheme.colors.palette.gray[700], true: defaultTheme.colors.palette.blue[500] }}
thumbColor={keepScreenOn ? defaultTheme.colors.palette.blue[400] : defaultTheme.colors.palette.gray[300]} thumbColor={settings.keepScreenOn ? defaultTheme.colors.palette.white : defaultTheme.colors.palette.gray[300]}
/> />
</View> </View>
</View> </View>
</View> </View>
{/* Theme Settings */} {/* Footer */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Theme</Text>
<View style={styles.themeCardDisabled}>
<Text style={styles.themeHelpText}>
Theme selection (coming soon)
</Text>
{(["dark", "light", "auto"] as const).map((themeOption) => (
<Pressable
key={themeOption}
disabled
style={styles.themeOption}
>
<View
style={[
styles.radioOuter,
theme === themeOption
? styles.radioOuterSelected
: styles.radioOuterUnselected,
]}
>
{theme === themeOption && (
<View style={styles.radioInner} />
)}
</View>
<Text style={styles.themeOptionText}>{themeOption}</Text>
</Pressable>
))}
</View>
</View>
{/* Action Buttons */}
<View style={styles.section}>
<Pressable
style={[
styles.saveButton,
!hasChanges && styles.saveButtonDisabled,
]}
onPress={handleSave}
disabled={!hasChanges}
>
<Text style={styles.saveButtonText}>Save Settings</Text>
</Pressable>
<Pressable style={styles.resetButton} onPress={handleReset}>
<Text style={styles.resetButtonText}>Reset to Defaults</Text>
</Pressable>
</View>
{/* App Info */}
<View style={styles.footer}> <View style={styles.footer}>
<Text style={styles.footerText}>Voice Assistant Mobile</Text> <View style={styles.footerAppInfo}>
<Text style={styles.footerVersion}>Version 1.0.0</Text> <Text style={styles.footerText}>Voice Dev Mobile</Text>
<Text style={styles.footerVersion}>Version 1.0.0</Text>
</View>
<Pressable style={styles.resetButton} onPress={handleReset}>
<Text style={styles.resetButtonText}>Reset to defaults</Text>
</Pressable>
</View> </View>
</View> </View>
</ScrollView> </ScrollView>
@@ -921,67 +884,73 @@ function DaemonCard({
]); ]);
}, [beginServerRestart, daemon.label, restartConfirmationMessage, restartServerFn]); }, [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 ( return (
<View style={[styles.settingCard, styles.daemonCard]}> <View style={styles.hostCard}>
<View style={styles.daemonHeaderRow}> <View style={styles.hostCardContent}>
<Text style={styles.settingTitle}>{daemon.label}</Text> <View style={styles.hostHeaderRow}>
<View style={styles.connectionStatusBadge}> <Text style={styles.hostLabel}>{daemon.label}</Text>
<View style={[styles.connectionStatusDot, { backgroundColor: statusColor }]} /> <View style={[styles.statusPill, { backgroundColor: statusPillBg }]}>
<Text style={[styles.connectionStatusText, { color: statusColor }]}>{badgeText}</Text> <View style={[styles.statusDot, { backgroundColor: statusColor }]} />
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
</View>
</View> </View>
<Text style={styles.hostUrl}>{daemon.wsUrl}</Text>
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
{testState && testState.status !== "idle" ? (
<Text style={[styles.testResultText, { color: testResultColor }]}>
{testState.message ?? (testState.status === "success" ? "Reachable" : "Testing...")}
</Text>
) : null}
</View> </View>
<Text style={styles.settingDescription}>{daemon.wsUrl}</Text> <View style={styles.hostActionsRow}>
{connectionError ? <Text style={styles.connectionErrorText}>{connectionError}</Text> : null}
{testState && testState.status !== "idle" ? (
<Text
style={
testState.status === "success"
? styles.testResultSuccessText
: testState.status === "error"
? styles.testResultErrorText
: styles.testResultInfoText
}
>
{testState.message ?? (testState.status === "success" ? "Reachable" : "Testing...")}
</Text>
) : null}
<View style={styles.daemonActionsRow}>
<Pressable <Pressable
style={[ style={[
styles.daemonActionButton, styles.hostActionButton,
styles.daemonActionPrimary, styles.hostActionPrimary,
isTesting && styles.daemonActionDisabled, isTesting && styles.hostActionDisabled,
]} ]}
onPress={() => onTestConnection(daemon)} onPress={() => onTestConnection(daemon)}
disabled={isTesting} disabled={isTesting}
> >
<Text style={[styles.daemonActionText, styles.daemonActionPrimaryText]}> <Text style={[styles.hostActionText, styles.hostActionPrimaryText]}>
{isTesting ? "Testing..." : "Test"} {isTesting ? "Testing..." : "Test"}
</Text> </Text>
</Pressable> </Pressable>
<View style={styles.hostActionSeparator} />
<Pressable <Pressable
style={[ style={[styles.hostActionButton, isRestarting && styles.hostActionDisabled]}
styles.daemonActionButton,
styles.daemonActionDestructive,
isRestarting && styles.daemonActionDisabled,
]}
onPress={handleRestartPress} onPress={handleRestartPress}
disabled={isRestarting} disabled={isRestarting}
> >
{isRestarting ? ( {isRestarting ? (
<ActivityIndicator size="small" color={defaultTheme.colors.destructive} /> <ActivityIndicator size="small" color={theme.colors.mutedForeground} />
) : ( ) : (
<Text style={[styles.daemonActionText, styles.daemonActionDestructiveText]}>Restart</Text> <Text style={styles.hostActionText}>Restart</Text>
)} )}
</Pressable> </Pressable>
<Pressable style={styles.daemonActionButton} onPress={() => onEdit(daemon)}> <Pressable style={styles.hostActionButton} onPress={() => onEdit(daemon)}>
<Text style={styles.daemonActionText}>Edit</Text> <Text style={styles.hostActionText}>Edit</Text>
</Pressable> </Pressable>
<Pressable <View style={styles.hostActionSeparator} />
style={[styles.daemonActionButton, styles.daemonActionDestructive]} <Pressable style={styles.hostActionButton} onPress={() => onRemove(daemon)}>
onPress={() => onRemove(daemon)} <Text style={[styles.hostActionText, styles.hostActionDestructiveText]}>Remove</Text>
>
<Text style={[styles.daemonActionText, styles.daemonActionDestructiveText]}>Remove</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>
+12 -15
View File
@@ -1,6 +1,6 @@
import { View, Text, Pressable, Modal, RefreshControl, type ListRenderItem } from "react-native"; import { View, Text, Pressable, Modal, RefreshControl, type ListRenderItem } from "react-native";
import { FlatList } from "react-native-gesture-handler"; 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 { router, usePathname } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
@@ -14,25 +14,21 @@ interface AgentListProps {
onRefresh?: () => void; onRefresh?: () => void;
selectedAgentId?: string; selectedAgentId?: string;
onAgentSelect?: () => void; 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 { theme } = useUnistyles();
const pathname = usePathname(); const pathname = usePathname();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null); const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(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 // Get the methods for the specific server
const methods = useSessionStore((state) => const methods = useSessionStore((state) =>
actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.methods : undefined actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.methods : undefined
@@ -148,7 +144,7 @@ export function AgentList({ agents, isRefreshing = false, onRefresh, selectedAge
return ( return (
<> <>
<FlatList <FlatList
data={sortedAgents} data={agents}
style={styles.list} style={styles.list}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
@@ -161,6 +157,7 @@ export function AgentList({ agents, isRefreshing = false, onRefresh, selectedAge
maxToRenderPerBatch={12} maxToRenderPerBatch={12}
updateCellsBatchingPeriod={16} updateCellsBatchingPeriod={16}
removeClippedSubviews={true} removeClippedSubviews={true}
ListFooterComponent={listFooterComponent}
refreshControl={ refreshControl={
onRefresh ? ( onRefresh ? (
<RefreshControl <RefreshControl
@@ -1,4 +1,4 @@
import { useCallback } from "react"; import { useCallback, useMemo } from "react";
import { View, Pressable, Text, Platform } from "react-native"; import { View, Pressable, Text, Platform } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, { import Animated, {
@@ -17,6 +17,7 @@ import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
const DESKTOP_SIDEBAR_WIDTH = 320; const DESKTOP_SIDEBAR_WIDTH = 320;
const SIDEBAR_AGENT_LIMIT = 15;
interface SlidingSidebarProps { interface SlidingSidebarProps {
selectedAgentId?: string; selectedAgentId?: string;
@@ -39,6 +40,20 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
const isMobile = const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
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]);
const limitedAgents = useMemo(
() => sortedAgents.slice(0, SIDEBAR_AGENT_LIMIT),
[sortedAgents]
);
const hasMore = agents.length > SIDEBAR_AGENT_LIMIT;
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
close(); close();
}, [close]); }, [close]);
@@ -74,6 +89,15 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
close(); close();
}, [close, translateX, backdropOpacity, windowWidth]); }, [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) // Close gesture (swipe left to close when sidebar is open)
const closeGesture = Gesture.Pan() const closeGesture = Gesture.Pan()
.enabled(isOpen) .enabled(isOpen)
@@ -121,6 +145,21 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
})); }));
const viewMoreButton = hasMore ? (
<View style={styles.viewMoreContainer}>
<Pressable
style={({ hovered }) => [
styles.newAgentButton,
styles.viewMoreButton,
hovered && styles.newAgentButtonHovered,
]}
onPress={handleViewMore}
>
<Text style={styles.viewMoreButtonText}>View More</Text>
</Pressable>
</View>
) : null;
// Render mobile sidebar // Render mobile sidebar
// On web, use "auto" instead of "box-none" because web's pointer-events: none blocks scroll // On web, use "auto" instead of "box-none" because web's pointer-events: none blocks scroll
const overlayPointerEvents = Platform.OS === "web" ? "auto" : "box-none"; const overlayPointerEvents = Platform.OS === "web" ? "auto" : "box-none";
@@ -158,11 +197,12 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
{/* Middle: scrollable agent list */} {/* Middle: scrollable agent list */}
<AgentList <AgentList
agents={agents} agents={limitedAgents}
isRefreshing={isRevalidating} isRefreshing={isRevalidating}
onRefresh={refreshAll} onRefresh={refreshAll}
selectedAgentId={selectedAgentId} selectedAgentId={selectedAgentId}
onAgentSelect={handleAgentSelectMobile} onAgentSelect={handleAgentSelectMobile}
listFooterComponent={viewMoreButton}
/> />
{/* Footer */} {/* Footer */}
@@ -205,10 +245,11 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
{/* Middle: scrollable agent list */} {/* Middle: scrollable agent list */}
<AgentList <AgentList
agents={agents} agents={limitedAgents}
isRefreshing={isRevalidating} isRefreshing={isRevalidating}
onRefresh={refreshAll} onRefresh={refreshAll}
selectedAgentId={selectedAgentId} selectedAgentId={selectedAgentId}
listFooterComponent={viewMoreButton}
/> />
{/* Footer: Settings button */} {/* Footer: Settings button */}
@@ -277,6 +318,17 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground, 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: { sidebarFooter: {
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[2], paddingTop: theme.spacing[2],