Improve sidebar agent list selection UI and drag behavior

This commit is contained in:
Mohamed Boudra
2026-03-01 15:44:58 +07:00
parent 2566171b4c
commit ec5b416b8a
7 changed files with 403 additions and 170 deletions
@@ -28,6 +28,7 @@ export function DraggableList<T>({
onRefresh,
simultaneousGestureRef,
waitFor,
onDragBegin: onDragBeginProp,
}: DraggableListProps<T>) {
const { theme } = useUnistyles();
const [isDragging, setIsDragging] = useState(false);
@@ -61,7 +62,8 @@ export function DraggableList<T>({
const handleDragBegin = useCallback(() => {
setIsDragging(true);
}, []);
onDragBeginProp?.();
}, [onDragBeginProp]);
const handleRelease = useCallback(() => {
setIsDragging(false);
@@ -30,4 +30,6 @@ export interface DraggableListProps<T> {
simultaneousGestureRef?: MutableRefObject<GestureType | undefined>;
/** Gesture ref(s) that the list should wait for before handling scroll */
waitFor?: MutableRefObject<GestureType | undefined> | MutableRefObject<GestureType | undefined>[];
/** Called when a drag gesture begins (before items are reordered) */
onDragBegin?: () => void;
}
@@ -110,6 +110,7 @@ export function DraggableList<T>({
showsVerticalScrollIndicator = true,
enableDesktopWebScrollbar = false,
// simultaneousGestureRef is native-only, ignored on web
onDragBegin,
}: DraggableListProps<T>) {
const [activeId, setActiveId] = useState<string | null>(null);
const [items, setItems] = useState(data);
@@ -134,7 +135,8 @@ export function DraggableList<T>({
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(String(event.active.id));
}, []);
onDragBegin?.();
}, [onDragBegin]);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
+27 -29
View File
@@ -398,21 +398,23 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
{projectFilterOptions.length > 0 ? (
<Pressable
ref={projectFilterAnchorRef}
style={styles.filterButton}
style={[
styles.filterButton,
selectedProjectFilterKey ? styles.filterButtonActive : null,
]}
onPress={() => setIsProjectFilterOpen(true)}
>
{({ hovered }) => (
<>
<ListFilter
size={theme.iconSize.md}
color={
(selectedProjectFilterKey || hovered)
<ListFilter
size={theme.iconSize.md}
color={
selectedProjectFilterKey
? theme.colors.accentForeground
: hovered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
{selectedProjectFilterKey ? <View style={styles.filterActiveDot} /> : null}
</>
}
/>
)}
</Pressable>
) : null}
@@ -553,21 +555,23 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
{projectFilterOptions.length > 0 ? (
<Pressable
ref={projectFilterAnchorRef}
style={styles.filterButton}
style={[
styles.filterButton,
selectedProjectFilterKey ? styles.filterButtonActive : null,
]}
onPress={() => setIsProjectFilterOpen(true)}
>
{({ hovered }) => (
<>
<ListFilter
size={theme.iconSize.md}
color={
(selectedProjectFilterKey || hovered)
<ListFilter
size={theme.iconSize.md}
color={
selectedProjectFilterKey
? theme.colors.accentForeground
: hovered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
{selectedProjectFilterKey ? <View style={styles.filterActiveDot} /> : null}
</>
}
/>
)}
</Pressable>
) : null}
@@ -727,16 +731,10 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
position: "relative",
borderRadius: theme.borderRadius.md,
},
filterActiveDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.borderAccent,
position: "absolute",
top: 0,
right: -2,
filterButtonActive: {
backgroundColor: theme.colors.accent,
},
newAgentButtonHovered: {},
newAgentButtonText: {
@@ -23,7 +23,7 @@ import {
useUnistyles,
} from "react-native-unistyles";
import { type GestureType } from "react-native-gesture-handler";
import { Archive, Check } from "lucide-react-native";
import { Archive, Check, X } from "lucide-react-native";
import {
DraggableList,
type DraggableRenderItemInfo,
@@ -149,7 +149,6 @@ interface SidebarAgentRowProps {
onPress: () => void;
onLongPress: () => void;
onArchive: () => Promise<void>;
onToggleBatch: () => void;
}
function resolveBranchLabel(entry: SidebarAgentListEntry): string | null {
@@ -176,7 +175,6 @@ function SidebarAgentRow({
onPress,
onLongPress,
onArchive,
onToggleBatch,
}: SidebarAgentRowProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
@@ -240,6 +238,7 @@ function SidebarAgentRow({
]}
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={200}
onPressIn={() => {
if (Platform.OS !== "web") {
return;
@@ -251,27 +250,10 @@ function SidebarAgentRow({
testID={`agent-row-${entry.agent.serverId}-${entry.agent.id}`}
>
<View style={styles.agentRowTop}>
{isInSelectionMode ? (
<Pressable
onPress={(event) => {
event.stopPropagation();
onToggleBatch();
}}
style={[
styles.checkbox,
isBatchSelected && styles.checkboxSelected,
]}
>
{isBatchSelected ? (
<Check size={theme.iconSize.xs} color={theme.colors.primaryForeground} />
) : null}
</Pressable>
) : (
<AgentStatusDot
status={entry.agent.status}
requiresAttention={entry.agent.requiresAttention}
/>
)}
<AgentStatusDot
status={entry.agent.status}
requiresAttention={entry.agent.requiresAttention}
/>
<Text
style={[
@@ -379,6 +361,16 @@ function SidebarAgentRow({
</View>
) : null}
</View>
{isBatchSelected ? (
<View style={styles.selectionOverlay} pointerEvents="none">
<Check
size={theme.iconSize.md}
color={theme.colors.accentForeground}
strokeWidth={2.5}
/>
</View>
) : null}
</Pressable>
);
}
@@ -607,9 +599,21 @@ export function SidebarAgentList({
const handleAgentLongPress = useCallback((entry: SidebarAgentListEntry) => {
const key = `${entry.agent.serverId}:${entry.agent.id}`;
if (isSelectionMode) {
setSelectedBatchKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
return;
}
setIsSelectionMode(true);
setSelectedBatchKeys(new Set([key]));
}, []);
}, [isSelectionMode]);
const handleArchiveSingle = useCallback(
async (entry: SidebarAgentListEntry): Promise<void> => {
@@ -667,8 +671,14 @@ export function SidebarAgentList({
setIsSelectionMode(false);
}, []);
const handleListDragBegin = useCallback(() => {
// No-op: selection mode is resolved in handleDragEnd based on whether
// the item actually moved. This fires synchronously when drag() is called
// (i.e., on long press), before we know if the user will drag or release.
}, []);
const renderRow = useCallback(
({ item }: DraggableRenderItemInfo<EntryData>) => {
({ item, drag }: DraggableRenderItemInfo<EntryData>) => {
const key = `${item.agent.serverId}:${item.agent.id}`;
const projectDisplayName = deriveProjectDisplayName({
projectKey: item.project.projectKey,
@@ -693,19 +703,11 @@ export function SidebarAgentList({
showShortcutBadges ? (shortcutIndexByAgentKey.get(key) ?? null) : null
}
onPress={() => handleAgentPress(item)}
onLongPress={() => handleAgentLongPress(item)}
onArchive={() => handleArchiveSingle(item)}
onToggleBatch={() => {
setSelectedBatchKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
onLongPress={() => {
drag();
handleAgentLongPress(item);
}}
onArchive={() => handleArchiveSingle(item)}
/>
);
},
@@ -735,8 +737,18 @@ export function SidebarAgentList({
const reorderedKeys = reorderedEntries.map(
(entry) => `${entry.agent.serverId}:${entry.agent.id}`
);
const currentOrder = getSidebarOrder(serverId);
const currentVisible = currentOrder.filter((key) =>
reorderedKeys.includes(key)
);
const orderChanged =
currentVisible.length !== reorderedKeys.length ||
reorderedKeys.some((key, i) => currentVisible[i] !== key);
if (!orderChanged) {
return;
}
const reorderedSet = new Set(reorderedKeys);
const remainder = getSidebarOrder(serverId).filter((key) => !reorderedSet.has(key));
const remainder = currentOrder.filter((key) => !reorderedSet.has(key));
setSidebarOrder(serverId, [...reorderedKeys, ...remainder]);
},
[getSidebarOrder, serverId, setSidebarOrder]
@@ -808,24 +820,39 @@ export function SidebarAgentList({
refreshing={isRefreshing}
onRefresh={onRefresh}
simultaneousGestureRef={parentGestureRef}
onDragBegin={handleListDragBegin}
/>
{isSelectionMode ? (
<View style={styles.selectionBar}>
<Pressable style={styles.selectionAction} onPress={handleSelectionBack}>
<Text style={styles.selectionActionText}>Back</Text>
</Pressable>
<Pressable
style={[
styles.selectionAction,
styles.selectionArchiveAction,
selectedBatchKeys.size === 0 && styles.selectionArchiveActionDisabled,
]}
disabled={selectedBatchKeys.size === 0}
onPress={handleArchiveBatch}
>
<Text style={styles.selectionArchiveActionText}>Archive</Text>
</Pressable>
<Text style={styles.selectionBarCountText}>
{selectedBatchKeys.size} selected
</Text>
<View style={styles.selectionBarActions}>
<Pressable
style={[
styles.selectionBarButton,
selectedBatchKeys.size === 0 && styles.selectionBarButtonDisabled,
]}
disabled={selectedBatchKeys.size === 0}
onPress={handleArchiveBatch}
>
<Archive
size={theme.iconSize.md}
color={theme.colors.accentForeground}
/>
</Pressable>
<Pressable
style={styles.selectionBarButton}
onPress={handleSelectionBack}
>
<X
size={theme.iconSize.md}
color={theme.colors.accentForeground}
strokeWidth={2.5}
/>
</Pressable>
</View>
</View>
) : null}
</View>
@@ -910,6 +937,7 @@ const styles = StyleSheet.create((theme) => ({
},
listContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
listContentSelectionMode: {
@@ -936,19 +964,19 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
minHeight: 20,
},
checkbox: {
width: theme.iconSize.md,
height: theme.iconSize.md,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
selectionOverlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: theme.borderRadius.lg,
borderWidth: 2,
borderColor: theme.colors.accent,
backgroundColor: `${theme.colors.accent}1F`,
alignItems: "center",
justifyContent: "center",
},
checkboxSelected: {
backgroundColor: theme.colors.primary,
borderColor: theme.colors.primary,
},
agentTitle: {
flex: 1,
fontSize: theme.fontSize.base,
@@ -1035,37 +1063,33 @@ const styles = StyleSheet.create((theme) => ({
left: theme.spacing[2],
right: theme.spacing[2],
bottom: theme.spacing[2],
borderRadius: theme.borderRadius.xl,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
padding: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
backgroundColor: theme.colors.accent,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
selectionBarCountText: {
color: theme.colors.accentForeground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
selectionBarActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
selectionAction: {
flex: 1,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
selectionBarButton: {
width: 36,
height: 36,
borderRadius: theme.borderRadius.full,
backgroundColor: "rgba(0, 0, 0, 0.15)",
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.surface1,
},
selectionActionText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
selectionArchiveAction: {
backgroundColor: theme.colors.primary,
borderColor: theme.colors.primary,
},
selectionArchiveActionDisabled: {
selectionBarButtonDisabled: {
opacity: 0.5,
},
selectionArchiveActionText: {
color: theme.colors.primaryForeground,
fontSize: theme.fontSize.sm,
},
}));