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
parent 0806fd7965
commit ecd9ddb4a2
5 changed files with 399 additions and 344 deletions

View File

@@ -218,6 +218,7 @@ export default function RootLayout() {
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="agents" />
<Stack.Screen name="orchestrator" />
<Stack.Screen name="agent/[id]" />
<Stack.Screen name="agent/[serverId]/[agentId]" />

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,
},
}));

View File

@@ -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() {
<Text style={styles.sectionTitle}>Hosts</Text>
{daemons.length === 0 ? (
<View style={styles.settingCard}>
<Text style={styles.settingDescription}>No hosts configured.</Text>
<View style={styles.emptyCard}>
<Text style={styles.emptyText}>No hosts configured</Text>
</View>
) : (
daemons.map((daemon) => {
@@ -613,46 +621,49 @@ export default function SettingsScreen() {
)}
{isDaemonFormVisible ? (
<View style={styles.settingCard}>
<Text style={styles.settingTitle}>{daemonForm.id ? "Edit Host" : "Add Host"}</Text>
<Text style={styles.label}>Label</Text>
<TextInput
style={styles.input}
value={daemonForm.label}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, label: text }))}
placeholder="My Host"
placeholderTextColor={defaultTheme.colors.mutedForeground}
/>
<Text style={styles.label}>WebSocket URL</Text>
<TextInput
style={styles.input}
value={daemonForm.wsUrl}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, wsUrl: text }))}
placeholder="wss://example.com/ws"
placeholderTextColor={defaultTheme.colors.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<View style={styles.daemonFormActionsRow}>
<Pressable style={[styles.daemonActionButton, styles.daemonActionDestructive]} onPress={handleCloseDaemonForm}>
<Text style={[styles.daemonActionText, styles.daemonActionDestructiveText]}>Cancel</Text>
<View style={styles.formCard}>
<Text style={styles.formTitle}>{daemonForm.id ? "Edit Host" : "Add Host"}</Text>
<View style={styles.formField}>
<Text style={styles.label}>Label</Text>
<TextInput
style={styles.input}
value={daemonForm.label}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, label: text }))}
placeholder="My Host"
placeholderTextColor={defaultTheme.colors.mutedForeground}
/>
</View>
<View style={styles.formField}>
<Text style={styles.label}>WebSocket URL</Text>
<TextInput
style={[styles.input, styles.inputUrl]}
value={daemonForm.wsUrl}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, wsUrl: text }))}
placeholder="wss://example.com/ws"
placeholderTextColor={defaultTheme.colors.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</View>
<View style={styles.formActionsRow}>
<Pressable style={styles.formButton} onPress={handleCloseDaemonForm}>
<Text style={styles.formButtonText}>Cancel</Text>
</Pressable>
<Pressable
style={[styles.daemonActionButton, styles.daemonActionPrimary, isSavingDaemon && styles.daemonActionDisabled]}
style={[styles.formButton, styles.formButtonPrimary, isSavingDaemon && styles.hostActionDisabled]}
onPress={handleSubmitDaemonForm}
disabled={isSavingDaemon}
>
<Text style={[styles.daemonActionText, styles.daemonActionPrimaryText]}>
{isSavingDaemon ? "Saving..." : daemonForm.id ? "Save Host" : "Add Host"}
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>
{isSavingDaemon ? "Saving..." : daemonForm.id ? "Save" : "Add"}
</Text>
</Pressable>
</View>
</View>
) : (
<Pressable style={styles.addButton} onPress={() => handleOpenDaemonForm()}>
<Text style={styles.addButtonText}>Add Host</Text>
<Text style={styles.addButtonText}>+ Add Host</Text>
</Pressable>
)}
</View>
@@ -661,96 +672,48 @@ export default function SettingsScreen() {
<View style={styles.section}>
<Text style={styles.sectionTitle}>Audio</Text>
<View style={styles.settingCard}>
<View style={styles.settingRow}>
<View style={styles.settingContent}>
<Text style={styles.settingTitle}>Use Speaker</Text>
<Text style={styles.settingDescription}>
<View style={styles.audioCard}>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Use Speaker</Text>
<Text style={styles.audioRowDescription}>
Play audio through speaker instead of earpiece
</Text>
</View>
<Switch
value={useSpeaker}
onValueChange={setUseSpeaker}
value={settings.useSpeaker}
onValueChange={handleToggleUseSpeaker}
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 style={styles.settingCard}>
<View style={styles.settingRow}>
<View style={styles.settingContent}>
<Text style={styles.settingTitle}>Keep Screen On</Text>
<Text style={styles.settingDescription}>
<View style={[styles.audioRow, styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Keep Screen On</Text>
<Text style={styles.audioRowDescription}>
Prevent screen from sleeping during voice sessions
</Text>
</View>
<Switch
value={keepScreenOn}
onValueChange={setKeepScreenOn}
value={settings.keepScreenOn}
onValueChange={handleToggleKeepScreenOn}
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>
{/* Theme Settings */}
<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 */}
{/* Footer */}
<View style={styles.footer}>
<Text style={styles.footerText}>Voice Assistant Mobile</Text>
<Text style={styles.footerVersion}>Version 1.0.0</Text>
<View style={styles.footerAppInfo}>
<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>
</ScrollView>
@@ -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 (
<View style={[styles.settingCard, styles.daemonCard]}>
<View style={styles.daemonHeaderRow}>
<Text style={styles.settingTitle}>{daemon.label}</Text>
<View style={styles.connectionStatusBadge}>
<View style={[styles.connectionStatusDot, { backgroundColor: statusColor }]} />
<Text style={[styles.connectionStatusText, { color: statusColor }]}>{badgeText}</Text>
<View style={styles.hostCard}>
<View style={styles.hostCardContent}>
<View style={styles.hostHeaderRow}>
<Text style={styles.hostLabel}>{daemon.label}</Text>
<View style={[styles.statusPill, { backgroundColor: statusPillBg }]}>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
</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>
<Text style={styles.settingDescription}>{daemon.wsUrl}</Text>
{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}>
<View style={styles.hostActionsRow}>
<Pressable
style={[
styles.daemonActionButton,
styles.daemonActionPrimary,
isTesting && styles.daemonActionDisabled,
styles.hostActionButton,
styles.hostActionPrimary,
isTesting && styles.hostActionDisabled,
]}
onPress={() => onTestConnection(daemon)}
disabled={isTesting}
>
<Text style={[styles.daemonActionText, styles.daemonActionPrimaryText]}>
<Text style={[styles.hostActionText, styles.hostActionPrimaryText]}>
{isTesting ? "Testing..." : "Test"}
</Text>
</Pressable>
<View style={styles.hostActionSeparator} />
<Pressable
style={[
styles.daemonActionButton,
styles.daemonActionDestructive,
isRestarting && styles.daemonActionDisabled,
]}
style={[styles.hostActionButton, isRestarting && styles.hostActionDisabled]}
onPress={handleRestartPress}
disabled={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 style={styles.daemonActionButton} onPress={() => onEdit(daemon)}>
<Text style={styles.daemonActionText}>Edit</Text>
<Pressable style={styles.hostActionButton} onPress={() => onEdit(daemon)}>
<Text style={styles.hostActionText}>Edit</Text>
</Pressable>
<Pressable
style={[styles.daemonActionButton, styles.daemonActionDestructive]}
onPress={() => onRemove(daemon)}
>
<Text style={[styles.daemonActionText, styles.daemonActionDestructiveText]}>Remove</Text>
<View style={styles.hostActionSeparator} />
<Pressable style={styles.hostActionButton} onPress={() => onRemove(daemon)}>
<Text style={[styles.hostActionText, styles.hostActionDestructiveText]}>Remove</Text>
</Pressable>
</View>
</View>

View File

@@ -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<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
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 (
<>
<FlatList
data={sortedAgents}
data={agents}
style={styles.list}
contentContainerStyle={styles.listContent}
keyExtractor={keyExtractor}
@@ -161,6 +157,7 @@ export function AgentList({ agents, isRefreshing = false, onRefresh, selectedAge
maxToRenderPerBatch={12}
updateCellsBatchingPeriod={16}
removeClippedSubviews={true}
ListFooterComponent={listFooterComponent}
refreshControl={
onRefresh ? (
<RefreshControl

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { View, Pressable, Text, Platform } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
@@ -17,6 +17,7 @@ import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
const DESKTOP_SIDEBAR_WIDTH = 320;
const SIDEBAR_AGENT_LIMIT = 15;
interface SlidingSidebarProps {
selectedAgentId?: string;
@@ -39,6 +40,20 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
const isMobile =
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(() => {
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 ? (
<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
// 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 */}
<AgentList
agents={agents}
agents={limitedAgents}
isRefreshing={isRevalidating}
onRefresh={refreshAll}
selectedAgentId={selectedAgentId}
onAgentSelect={handleAgentSelectMobile}
listFooterComponent={viewMoreButton}
/>
{/* Footer */}
@@ -205,10 +245,11 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
{/* Middle: scrollable agent list */}
<AgentList
agents={agents}
agents={limitedAgents}
isRefreshing={isRevalidating}
onRefresh={refreshAll}
selectedAgentId={selectedAgentId}
listFooterComponent={viewMoreButton}
/>
{/* 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],