Improve sidebar agent list selection UI and drag behavior
This commit is contained in:
@@ -28,6 +28,7 @@ export function DraggableList<T>({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
simultaneousGestureRef,
|
simultaneousGestureRef,
|
||||||
waitFor,
|
waitFor,
|
||||||
|
onDragBegin: onDragBeginProp,
|
||||||
}: DraggableListProps<T>) {
|
}: DraggableListProps<T>) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
@@ -61,7 +62,8 @@ export function DraggableList<T>({
|
|||||||
|
|
||||||
const handleDragBegin = useCallback(() => {
|
const handleDragBegin = useCallback(() => {
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
}, []);
|
onDragBeginProp?.();
|
||||||
|
}, [onDragBeginProp]);
|
||||||
|
|
||||||
const handleRelease = useCallback(() => {
|
const handleRelease = useCallback(() => {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
|||||||
@@ -30,4 +30,6 @@ export interface DraggableListProps<T> {
|
|||||||
simultaneousGestureRef?: MutableRefObject<GestureType | undefined>;
|
simultaneousGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||||
/** Gesture ref(s) that the list should wait for before handling scroll */
|
/** Gesture ref(s) that the list should wait for before handling scroll */
|
||||||
waitFor?: MutableRefObject<GestureType | undefined> | MutableRefObject<GestureType | undefined>[];
|
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,
|
showsVerticalScrollIndicator = true,
|
||||||
enableDesktopWebScrollbar = false,
|
enableDesktopWebScrollbar = false,
|
||||||
// simultaneousGestureRef is native-only, ignored on web
|
// simultaneousGestureRef is native-only, ignored on web
|
||||||
|
onDragBegin,
|
||||||
}: DraggableListProps<T>) {
|
}: DraggableListProps<T>) {
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [items, setItems] = useState(data);
|
const [items, setItems] = useState(data);
|
||||||
@@ -134,7 +135,8 @@ export function DraggableList<T>({
|
|||||||
|
|
||||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
setActiveId(String(event.active.id));
|
setActiveId(String(event.active.id));
|
||||||
}, []);
|
onDragBegin?.();
|
||||||
|
}, [onDragBegin]);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
(event: DragEndEvent) => {
|
(event: DragEndEvent) => {
|
||||||
|
|||||||
@@ -398,21 +398,23 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
|||||||
{projectFilterOptions.length > 0 ? (
|
{projectFilterOptions.length > 0 ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
ref={projectFilterAnchorRef}
|
ref={projectFilterAnchorRef}
|
||||||
style={styles.filterButton}
|
style={[
|
||||||
|
styles.filterButton,
|
||||||
|
selectedProjectFilterKey ? styles.filterButtonActive : null,
|
||||||
|
]}
|
||||||
onPress={() => setIsProjectFilterOpen(true)}
|
onPress={() => setIsProjectFilterOpen(true)}
|
||||||
>
|
>
|
||||||
{({ hovered }) => (
|
{({ hovered }) => (
|
||||||
<>
|
<ListFilter
|
||||||
<ListFilter
|
size={theme.iconSize.md}
|
||||||
size={theme.iconSize.md}
|
color={
|
||||||
color={
|
selectedProjectFilterKey
|
||||||
(selectedProjectFilterKey || hovered)
|
? theme.colors.accentForeground
|
||||||
|
: hovered
|
||||||
? theme.colors.foreground
|
? theme.colors.foreground
|
||||||
: theme.colors.foregroundMuted
|
: theme.colors.foregroundMuted
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{selectedProjectFilterKey ? <View style={styles.filterActiveDot} /> : null}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -553,21 +555,23 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
|||||||
{projectFilterOptions.length > 0 ? (
|
{projectFilterOptions.length > 0 ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
ref={projectFilterAnchorRef}
|
ref={projectFilterAnchorRef}
|
||||||
style={styles.filterButton}
|
style={[
|
||||||
|
styles.filterButton,
|
||||||
|
selectedProjectFilterKey ? styles.filterButtonActive : null,
|
||||||
|
]}
|
||||||
onPress={() => setIsProjectFilterOpen(true)}
|
onPress={() => setIsProjectFilterOpen(true)}
|
||||||
>
|
>
|
||||||
{({ hovered }) => (
|
{({ hovered }) => (
|
||||||
<>
|
<ListFilter
|
||||||
<ListFilter
|
size={theme.iconSize.md}
|
||||||
size={theme.iconSize.md}
|
color={
|
||||||
color={
|
selectedProjectFilterKey
|
||||||
(selectedProjectFilterKey || hovered)
|
? theme.colors.accentForeground
|
||||||
|
: hovered
|
||||||
? theme.colors.foreground
|
? theme.colors.foreground
|
||||||
: theme.colors.foregroundMuted
|
: theme.colors.foregroundMuted
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{selectedProjectFilterKey ? <View style={styles.filterActiveDot} /> : null}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -727,16 +731,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[1],
|
paddingHorizontal: theme.spacing[1],
|
||||||
position: "relative",
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
filterActiveDot: {
|
filterButtonActive: {
|
||||||
width: 8,
|
backgroundColor: theme.colors.accent,
|
||||||
height: 8,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
backgroundColor: theme.colors.borderAccent,
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
right: -2,
|
|
||||||
},
|
},
|
||||||
newAgentButtonHovered: {},
|
newAgentButtonHovered: {},
|
||||||
newAgentButtonText: {
|
newAgentButtonText: {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
useUnistyles,
|
useUnistyles,
|
||||||
} from "react-native-unistyles";
|
} from "react-native-unistyles";
|
||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { Archive, Check } from "lucide-react-native";
|
import { Archive, Check, X } from "lucide-react-native";
|
||||||
import {
|
import {
|
||||||
DraggableList,
|
DraggableList,
|
||||||
type DraggableRenderItemInfo,
|
type DraggableRenderItemInfo,
|
||||||
@@ -149,7 +149,6 @@ interface SidebarAgentRowProps {
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
onLongPress: () => void;
|
onLongPress: () => void;
|
||||||
onArchive: () => Promise<void>;
|
onArchive: () => Promise<void>;
|
||||||
onToggleBatch: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBranchLabel(entry: SidebarAgentListEntry): string | null {
|
function resolveBranchLabel(entry: SidebarAgentListEntry): string | null {
|
||||||
@@ -176,7 +175,6 @@ function SidebarAgentRow({
|
|||||||
onPress,
|
onPress,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
onArchive,
|
onArchive,
|
||||||
onToggleBatch,
|
|
||||||
}: SidebarAgentRowProps) {
|
}: SidebarAgentRowProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
@@ -240,6 +238,7 @@ function SidebarAgentRow({
|
|||||||
]}
|
]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onLongPress={onLongPress}
|
onLongPress={onLongPress}
|
||||||
|
delayLongPress={200}
|
||||||
onPressIn={() => {
|
onPressIn={() => {
|
||||||
if (Platform.OS !== "web") {
|
if (Platform.OS !== "web") {
|
||||||
return;
|
return;
|
||||||
@@ -251,27 +250,10 @@ function SidebarAgentRow({
|
|||||||
testID={`agent-row-${entry.agent.serverId}-${entry.agent.id}`}
|
testID={`agent-row-${entry.agent.serverId}-${entry.agent.id}`}
|
||||||
>
|
>
|
||||||
<View style={styles.agentRowTop}>
|
<View style={styles.agentRowTop}>
|
||||||
{isInSelectionMode ? (
|
<AgentStatusDot
|
||||||
<Pressable
|
status={entry.agent.status}
|
||||||
onPress={(event) => {
|
requiresAttention={entry.agent.requiresAttention}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -379,6 +361,16 @@ function SidebarAgentRow({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{isBatchSelected ? (
|
||||||
|
<View style={styles.selectionOverlay} pointerEvents="none">
|
||||||
|
<Check
|
||||||
|
size={theme.iconSize.md}
|
||||||
|
color={theme.colors.accentForeground}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -607,9 +599,21 @@ export function SidebarAgentList({
|
|||||||
|
|
||||||
const handleAgentLongPress = useCallback((entry: SidebarAgentListEntry) => {
|
const handleAgentLongPress = useCallback((entry: SidebarAgentListEntry) => {
|
||||||
const key = `${entry.agent.serverId}:${entry.agent.id}`;
|
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);
|
setIsSelectionMode(true);
|
||||||
setSelectedBatchKeys(new Set([key]));
|
setSelectedBatchKeys(new Set([key]));
|
||||||
}, []);
|
}, [isSelectionMode]);
|
||||||
|
|
||||||
const handleArchiveSingle = useCallback(
|
const handleArchiveSingle = useCallback(
|
||||||
async (entry: SidebarAgentListEntry): Promise<void> => {
|
async (entry: SidebarAgentListEntry): Promise<void> => {
|
||||||
@@ -667,8 +671,14 @@ export function SidebarAgentList({
|
|||||||
setIsSelectionMode(false);
|
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(
|
const renderRow = useCallback(
|
||||||
({ item }: DraggableRenderItemInfo<EntryData>) => {
|
({ item, drag }: DraggableRenderItemInfo<EntryData>) => {
|
||||||
const key = `${item.agent.serverId}:${item.agent.id}`;
|
const key = `${item.agent.serverId}:${item.agent.id}`;
|
||||||
const projectDisplayName = deriveProjectDisplayName({
|
const projectDisplayName = deriveProjectDisplayName({
|
||||||
projectKey: item.project.projectKey,
|
projectKey: item.project.projectKey,
|
||||||
@@ -693,19 +703,11 @@ export function SidebarAgentList({
|
|||||||
showShortcutBadges ? (shortcutIndexByAgentKey.get(key) ?? null) : null
|
showShortcutBadges ? (shortcutIndexByAgentKey.get(key) ?? null) : null
|
||||||
}
|
}
|
||||||
onPress={() => handleAgentPress(item)}
|
onPress={() => handleAgentPress(item)}
|
||||||
onLongPress={() => handleAgentLongPress(item)}
|
onLongPress={() => {
|
||||||
onArchive={() => handleArchiveSingle(item)}
|
drag();
|
||||||
onToggleBatch={() => {
|
handleAgentLongPress(item);
|
||||||
setSelectedBatchKeys((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(key)) {
|
|
||||||
next.delete(key);
|
|
||||||
} else {
|
|
||||||
next.add(key);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
|
onArchive={() => handleArchiveSingle(item)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -735,8 +737,18 @@ export function SidebarAgentList({
|
|||||||
const reorderedKeys = reorderedEntries.map(
|
const reorderedKeys = reorderedEntries.map(
|
||||||
(entry) => `${entry.agent.serverId}:${entry.agent.id}`
|
(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 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]);
|
setSidebarOrder(serverId, [...reorderedKeys, ...remainder]);
|
||||||
},
|
},
|
||||||
[getSidebarOrder, serverId, setSidebarOrder]
|
[getSidebarOrder, serverId, setSidebarOrder]
|
||||||
@@ -808,24 +820,39 @@ export function SidebarAgentList({
|
|||||||
refreshing={isRefreshing}
|
refreshing={isRefreshing}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
simultaneousGestureRef={parentGestureRef}
|
simultaneousGestureRef={parentGestureRef}
|
||||||
|
onDragBegin={handleListDragBegin}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isSelectionMode ? (
|
{isSelectionMode ? (
|
||||||
<View style={styles.selectionBar}>
|
<View style={styles.selectionBar}>
|
||||||
<Pressable style={styles.selectionAction} onPress={handleSelectionBack}>
|
<Text style={styles.selectionBarCountText}>
|
||||||
<Text style={styles.selectionActionText}>Back</Text>
|
{selectedBatchKeys.size} selected
|
||||||
</Pressable>
|
</Text>
|
||||||
<Pressable
|
<View style={styles.selectionBarActions}>
|
||||||
style={[
|
<Pressable
|
||||||
styles.selectionAction,
|
style={[
|
||||||
styles.selectionArchiveAction,
|
styles.selectionBarButton,
|
||||||
selectedBatchKeys.size === 0 && styles.selectionArchiveActionDisabled,
|
selectedBatchKeys.size === 0 && styles.selectionBarButtonDisabled,
|
||||||
]}
|
]}
|
||||||
disabled={selectedBatchKeys.size === 0}
|
disabled={selectedBatchKeys.size === 0}
|
||||||
onPress={handleArchiveBatch}
|
onPress={handleArchiveBatch}
|
||||||
>
|
>
|
||||||
<Text style={styles.selectionArchiveActionText}>Archive</Text>
|
<Archive
|
||||||
</Pressable>
|
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>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -910,6 +937,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
listContent: {
|
listContent: {
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
paddingTop: theme.spacing[2],
|
||||||
paddingBottom: theme.spacing[4],
|
paddingBottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
listContentSelectionMode: {
|
listContentSelectionMode: {
|
||||||
@@ -936,19 +964,19 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
minHeight: 20,
|
minHeight: 20,
|
||||||
},
|
},
|
||||||
checkbox: {
|
selectionOverlay: {
|
||||||
width: theme.iconSize.md,
|
position: "absolute",
|
||||||
height: theme.iconSize.md,
|
top: 0,
|
||||||
borderRadius: theme.borderRadius.sm,
|
left: 0,
|
||||||
borderWidth: 1,
|
right: 0,
|
||||||
borderColor: theme.colors.border,
|
bottom: 0,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: theme.colors.accent,
|
||||||
|
backgroundColor: `${theme.colors.accent}1F`,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
checkboxSelected: {
|
|
||||||
backgroundColor: theme.colors.primary,
|
|
||||||
borderColor: theme.colors.primary,
|
|
||||||
},
|
|
||||||
agentTitle: {
|
agentTitle: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
@@ -1035,37 +1063,33 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
left: theme.spacing[2],
|
left: theme.spacing[2],
|
||||||
right: theme.spacing[2],
|
right: theme.spacing[2],
|
||||||
bottom: theme.spacing[2],
|
bottom: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.xl,
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
borderWidth: 1,
|
backgroundColor: theme.colors.accent,
|
||||||
borderColor: theme.colors.border,
|
paddingVertical: theme.spacing[3],
|
||||||
backgroundColor: theme.colors.surface2,
|
paddingHorizontal: theme.spacing[4],
|
||||||
padding: theme.spacing[2],
|
|
||||||
flexDirection: "row",
|
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],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
selectionAction: {
|
selectionBarButton: {
|
||||||
flex: 1,
|
width: 36,
|
||||||
borderRadius: theme.borderRadius.lg,
|
height: 36,
|
||||||
borderWidth: 1,
|
borderRadius: theme.borderRadius.full,
|
||||||
borderColor: theme.colors.border,
|
backgroundColor: "rgba(0, 0, 0, 0.15)",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
},
|
},
|
||||||
selectionActionText: {
|
selectionBarButtonDisabled: {
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
selectionArchiveAction: {
|
|
||||||
backgroundColor: theme.colors.primary,
|
|
||||||
borderColor: theme.colors.primary,
|
|
||||||
},
|
|
||||||
selectionArchiveActionDisabled: {
|
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
selectionArchiveActionText: {
|
|
||||||
color: theme.colors.primaryForeground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||||
|
import {
|
||||||
|
applySidebarUserOrdering,
|
||||||
|
type SidebarAgentListEntry,
|
||||||
|
} from "./use-sidebar-agents-list";
|
||||||
|
|
||||||
|
function toKey(entry: SidebarAgentListEntry): string {
|
||||||
|
return `${entry.agent.serverId}:${entry.agent.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEntry(input: {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
status?: AgentLifecycleStatus;
|
||||||
|
serverId?: string;
|
||||||
|
}): SidebarAgentListEntry {
|
||||||
|
const serverId = input.serverId ?? "server";
|
||||||
|
const createdAt = new Date(input.createdAt);
|
||||||
|
return {
|
||||||
|
agent: {
|
||||||
|
id: input.id,
|
||||||
|
serverId,
|
||||||
|
serverLabel: serverId,
|
||||||
|
title: input.id,
|
||||||
|
status: input.status ?? "idle",
|
||||||
|
createdAt,
|
||||||
|
lastActivityAt: createdAt,
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
provider: "codex",
|
||||||
|
pendingPermissionCount: 0,
|
||||||
|
requiresAttention: false,
|
||||||
|
attentionReason: null,
|
||||||
|
attentionTimestamp: null,
|
||||||
|
archivedAt: null,
|
||||||
|
labels: { ui: "true" },
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
projectKey: "project",
|
||||||
|
projectName: "Project",
|
||||||
|
checkout: {
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
isGit: false,
|
||||||
|
currentBranch: null,
|
||||||
|
remoteUrl: null,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("applySidebarUserOrdering", () => {
|
||||||
|
it("keeps unknown entries on the chronological baseline", () => {
|
||||||
|
const entries = [
|
||||||
|
createEntry({ id: "a", createdAt: "2026-03-01T10:00:00.000Z" }),
|
||||||
|
createEntry({ id: "b", createdAt: "2026-02-28T10:00:00.000Z" }),
|
||||||
|
createEntry({ id: "x", createdAt: "2026-02-01T10:00:00.000Z" }),
|
||||||
|
];
|
||||||
|
const result = applySidebarUserOrdering({
|
||||||
|
entries,
|
||||||
|
order: ["server:b", "server:a"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.entries.map(toKey)).toEqual(["server:b", "server:a", "server:x"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places new entries first while preserving persisted order for known entries", () => {
|
||||||
|
const entries = [
|
||||||
|
createEntry({ id: "new", createdAt: "2026-03-02T10:00:00.000Z" }),
|
||||||
|
createEntry({ id: "a", createdAt: "2026-03-01T10:00:00.000Z" }),
|
||||||
|
createEntry({ id: "b", createdAt: "2026-02-28T10:00:00.000Z" }),
|
||||||
|
];
|
||||||
|
const result = applySidebarUserOrdering({
|
||||||
|
entries,
|
||||||
|
order: ["server:b", "server:a"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.entries.map(toKey)).toEqual([
|
||||||
|
"server:new",
|
||||||
|
"server:b",
|
||||||
|
"server:a",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps persisted placement stable across status changes", () => {
|
||||||
|
const baseEntries = [
|
||||||
|
createEntry({
|
||||||
|
id: "a",
|
||||||
|
createdAt: "2026-03-01T10:00:00.000Z",
|
||||||
|
status: "running",
|
||||||
|
}),
|
||||||
|
createEntry({
|
||||||
|
id: "b",
|
||||||
|
createdAt: "2026-02-28T10:00:00.000Z",
|
||||||
|
status: "idle",
|
||||||
|
}),
|
||||||
|
createEntry({
|
||||||
|
id: "c",
|
||||||
|
createdAt: "2026-02-27T10:00:00.000Z",
|
||||||
|
status: "idle",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const order = ["server:c", "server:a", "server:b"];
|
||||||
|
|
||||||
|
const initial = applySidebarUserOrdering({ entries: baseEntries, order });
|
||||||
|
expect(initial.entries.map(toKey)).toEqual(order);
|
||||||
|
|
||||||
|
const afterStatusChange = applySidebarUserOrdering({
|
||||||
|
entries: baseEntries.map((entry) =>
|
||||||
|
entry.agent.id === "a"
|
||||||
|
? {
|
||||||
|
...entry,
|
||||||
|
agent: {
|
||||||
|
...entry.agent,
|
||||||
|
status: "idle",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: entry
|
||||||
|
),
|
||||||
|
order,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(afterStatusChange.entries.map(toKey)).toEqual(order);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always includes active entries while capping done entries to 50", () => {
|
||||||
|
const doneEntries = Array.from({ length: 55 }, (_, index) =>
|
||||||
|
createEntry({
|
||||||
|
id: `done-${index + 1}`,
|
||||||
|
createdAt: new Date(
|
||||||
|
Date.UTC(2026, 2, 1, 12, 0, 0) - index * 60_000
|
||||||
|
).toISOString(),
|
||||||
|
status: "idle",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const activeEntry = createEntry({
|
||||||
|
id: "active-oldest",
|
||||||
|
createdAt: "2026-01-01T10:00:00.000Z",
|
||||||
|
status: "running",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = applySidebarUserOrdering({
|
||||||
|
entries: [...doneEntries, activeEntry],
|
||||||
|
order: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.entries).toHaveLength(51);
|
||||||
|
expect(result.entries.some((entry) => entry.agent.id === "active-oldest")).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(result.entries.filter((entry) => entry.agent.status === "idle")).toHaveLength(
|
||||||
|
50
|
||||||
|
);
|
||||||
|
expect(result.hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritizes newest done entries for the 50-item done window", () => {
|
||||||
|
const oldDone = Array.from({ length: 55 }, (_, index) =>
|
||||||
|
createEntry({
|
||||||
|
id: `old-${index + 1}`,
|
||||||
|
createdAt: new Date(
|
||||||
|
Date.UTC(2026, 1, 1, 12, 0, 0) - index * 60_000
|
||||||
|
).toISOString(),
|
||||||
|
status: "idle",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const recentDone = [
|
||||||
|
createEntry({ id: "recent-1", createdAt: "2026-03-01T12:00:00.000Z" }),
|
||||||
|
createEntry({ id: "recent-2", createdAt: "2026-03-01T11:59:00.000Z" }),
|
||||||
|
createEntry({ id: "recent-3", createdAt: "2026-03-01T11:58:00.000Z" }),
|
||||||
|
];
|
||||||
|
const entries = [...oldDone, ...recentDone];
|
||||||
|
const pollutedOrder = [...oldDone, ...recentDone].map(toKey);
|
||||||
|
|
||||||
|
const result = applySidebarUserOrdering({
|
||||||
|
entries,
|
||||||
|
order: pollutedOrder,
|
||||||
|
});
|
||||||
|
const visibleKeys = result.entries.map(toKey);
|
||||||
|
|
||||||
|
expect(result.entries).toHaveLength(50);
|
||||||
|
expect(visibleKeys).toContain("server:recent-1");
|
||||||
|
expect(visibleKeys).toContain("server:recent-2");
|
||||||
|
expect(visibleKeys).toContain("server:recent-3");
|
||||||
|
expect(visibleKeys).not.toContain("server:old-55");
|
||||||
|
expect(result.hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -68,69 +68,85 @@ function toSidebarAgentKey(entry: SidebarAgentListEntry): string {
|
|||||||
return `${entry.agent.serverId}:${entry.agent.id}`;
|
return `${entry.agent.serverId}:${entry.agent.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySidebarUserOrdering(input: {
|
export function applySidebarUserOrdering(input: {
|
||||||
entries: SidebarAgentListEntry[];
|
entries: SidebarAgentListEntry[];
|
||||||
order: string[];
|
order: string[];
|
||||||
}): { entries: SidebarAgentListEntry[]; hasMore: boolean } {
|
}): { entries: SidebarAgentListEntry[]; hasMore: boolean } {
|
||||||
const entryKeySet = new Set(input.entries.map((entry) => toSidebarAgentKey(entry)));
|
const sortedByCreatedAt = [...input.entries].sort(compareByCreatedAtDesc);
|
||||||
const prunedOrder = input.order.filter((key) => entryKeySet.has(key));
|
const entryByKey = new Map<string, SidebarAgentListEntry>();
|
||||||
const knownOrderSet = new Set(prunedOrder);
|
for (const entry of sortedByCreatedAt) {
|
||||||
const newEntries = input.entries
|
entryByKey.set(toSidebarAgentKey(entry), entry);
|
||||||
.filter((entry) => !knownOrderSet.has(toSidebarAgentKey(entry)))
|
|
||||||
.sort(compareByCreatedAtDesc);
|
|
||||||
const effectiveOrder = [
|
|
||||||
...newEntries.map((entry) => toSidebarAgentKey(entry)),
|
|
||||||
...prunedOrder,
|
|
||||||
];
|
|
||||||
const orderIndexByKey = new Map<string, number>();
|
|
||||||
for (let index = 0; index < effectiveOrder.length; index += 1) {
|
|
||||||
orderIndexByKey.set(effectiveOrder[index] ?? "", index);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = [...input.entries].sort((left, right) => {
|
const prunedOrder: string[] = [];
|
||||||
const leftOrder = orderIndexByKey.get(toSidebarAgentKey(left));
|
const seenOrderKeys = new Set<string>();
|
||||||
const rightOrder = orderIndexByKey.get(toSidebarAgentKey(right));
|
for (const key of input.order) {
|
||||||
|
if (!entryByKey.has(key) || seenOrderKeys.has(key)) {
|
||||||
if (leftOrder === undefined && rightOrder === undefined) {
|
|
||||||
return compareByCreatedAtDesc(left, right);
|
|
||||||
}
|
|
||||||
if (leftOrder === undefined) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (rightOrder === undefined) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return leftOrder - rightOrder;
|
|
||||||
});
|
|
||||||
|
|
||||||
const active: SidebarAgentListEntry[] = [];
|
|
||||||
const done: SidebarAgentListEntry[] = [];
|
|
||||||
for (const entry of sorted) {
|
|
||||||
const isActive = isSidebarActiveAgent({
|
|
||||||
status: entry.agent.status,
|
|
||||||
pendingPermissionCount: entry.agent.pendingPermissionCount,
|
|
||||||
requiresAttention: entry.agent.requiresAttention,
|
|
||||||
attentionReason: entry.agent.attentionReason,
|
|
||||||
});
|
|
||||||
if (isActive) {
|
|
||||||
active.push(entry);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
done.push(entry);
|
seenOrderKeys.add(key);
|
||||||
|
prunedOrder.push(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (active.length >= SIDEBAR_DONE_FILL_TARGET) {
|
const sorted: SidebarAgentListEntry[] = [];
|
||||||
return {
|
if (prunedOrder.length === 0) {
|
||||||
entries: active,
|
sorted.push(...sortedByCreatedAt);
|
||||||
hasMore: done.length > 0,
|
} else {
|
||||||
};
|
const knownOrderSet = new Set(prunedOrder);
|
||||||
|
let knownOrderIndex = 0;
|
||||||
|
for (const entry of sortedByCreatedAt) {
|
||||||
|
const key = toSidebarAgentKey(entry);
|
||||||
|
if (!knownOrderSet.has(key)) {
|
||||||
|
sorted.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const orderedKey = prunedOrder[knownOrderIndex] ?? key;
|
||||||
|
knownOrderIndex += 1;
|
||||||
|
sorted.push(entryByKey.get(orderedKey) ?? entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doneEntries = sorted.filter(
|
||||||
|
(entry) =>
|
||||||
|
!isSidebarActiveAgent({
|
||||||
|
status: entry.agent.status,
|
||||||
|
pendingPermissionCount: entry.agent.pendingPermissionCount,
|
||||||
|
requiresAttention: entry.agent.requiresAttention,
|
||||||
|
attentionReason: entry.agent.attentionReason,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const visibleDoneKeys = new Set(
|
||||||
|
[...doneEntries]
|
||||||
|
.sort(compareByCreatedAtDesc)
|
||||||
|
.slice(0, SIDEBAR_DONE_FILL_TARGET)
|
||||||
|
.map((entry) => toSidebarAgentKey(entry))
|
||||||
|
);
|
||||||
|
|
||||||
|
const visible: SidebarAgentListEntry[] = [];
|
||||||
|
let hiddenDoneCount = 0;
|
||||||
|
for (const entry of sorted) {
|
||||||
|
const key = toSidebarAgentKey(entry);
|
||||||
|
if (
|
||||||
|
isSidebarActiveAgent({
|
||||||
|
status: entry.agent.status,
|
||||||
|
pendingPermissionCount: entry.agent.pendingPermissionCount,
|
||||||
|
requiresAttention: entry.agent.requiresAttention,
|
||||||
|
attentionReason: entry.agent.attentionReason,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
visible.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (visibleDoneKeys.has(key)) {
|
||||||
|
visible.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hiddenDoneCount += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingDoneSlots = SIDEBAR_DONE_FILL_TARGET - active.length;
|
|
||||||
const shownDone = done.slice(0, remainingDoneSlots);
|
|
||||||
return {
|
return {
|
||||||
entries: [...active, ...shownDone],
|
entries: visible,
|
||||||
hasMore: done.length > shownDone.length,
|
hasMore: hiddenDoneCount > 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user