From ec5b416b8ab51b72a6df7a78a097777e543bd70e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Mar 2026 15:44:58 +0700 Subject: [PATCH] Improve sidebar agent list selection UI and drag behavior --- .../src/components/draggable-list.native.tsx | 4 +- .../src/components/draggable-list.types.ts | 2 + .../app/src/components/draggable-list.web.tsx | 4 +- packages/app/src/components/left-sidebar.tsx | 56 +++-- .../app/src/components/sidebar-agent-list.tsx | 200 ++++++++++-------- .../src/hooks/use-sidebar-agents-list.test.ts | 189 +++++++++++++++++ .../app/src/hooks/use-sidebar-agents-list.ts | 118 ++++++----- 7 files changed, 403 insertions(+), 170 deletions(-) create mode 100644 packages/app/src/hooks/use-sidebar-agents-list.test.ts diff --git a/packages/app/src/components/draggable-list.native.tsx b/packages/app/src/components/draggable-list.native.tsx index 05432c039..42a478ed8 100644 --- a/packages/app/src/components/draggable-list.native.tsx +++ b/packages/app/src/components/draggable-list.native.tsx @@ -28,6 +28,7 @@ export function DraggableList({ onRefresh, simultaneousGestureRef, waitFor, + onDragBegin: onDragBeginProp, }: DraggableListProps) { const { theme } = useUnistyles(); const [isDragging, setIsDragging] = useState(false); @@ -61,7 +62,8 @@ export function DraggableList({ const handleDragBegin = useCallback(() => { setIsDragging(true); - }, []); + onDragBeginProp?.(); + }, [onDragBeginProp]); const handleRelease = useCallback(() => { setIsDragging(false); diff --git a/packages/app/src/components/draggable-list.types.ts b/packages/app/src/components/draggable-list.types.ts index 85a2df84b..043f2e5c8 100644 --- a/packages/app/src/components/draggable-list.types.ts +++ b/packages/app/src/components/draggable-list.types.ts @@ -30,4 +30,6 @@ export interface DraggableListProps { simultaneousGestureRef?: MutableRefObject; /** Gesture ref(s) that the list should wait for before handling scroll */ waitFor?: MutableRefObject | MutableRefObject[]; + /** Called when a drag gesture begins (before items are reordered) */ + onDragBegin?: () => void; } diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index 5a2649d4e..14195a326 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -110,6 +110,7 @@ export function DraggableList({ showsVerticalScrollIndicator = true, enableDesktopWebScrollbar = false, // simultaneousGestureRef is native-only, ignored on web + onDragBegin, }: DraggableListProps) { const [activeId, setActiveId] = useState(null); const [items, setItems] = useState(data); @@ -134,7 +135,8 @@ export function DraggableList({ const handleDragStart = useCallback((event: DragStartEvent) => { setActiveId(String(event.active.id)); - }, []); + onDragBegin?.(); + }, [onDragBegin]); const handleDragEnd = useCallback( (event: DragEndEvent) => { diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index e8410b43f..ca7c988a5 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -398,21 +398,23 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { {projectFilterOptions.length > 0 ? ( setIsProjectFilterOpen(true)} > {({ hovered }) => ( - <> - - {selectedProjectFilterKey ? : null} - + } + /> )} ) : null} @@ -553,21 +555,23 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { {projectFilterOptions.length > 0 ? ( setIsProjectFilterOpen(true)} > {({ hovered }) => ( - <> - - {selectedProjectFilterKey ? : null} - + } + /> )} ) : 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: { diff --git a/packages/app/src/components/sidebar-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx index 84abe2bb0..e8ee37c46 100644 --- a/packages/app/src/components/sidebar-agent-list.tsx +++ b/packages/app/src/components/sidebar-agent-list.tsx @@ -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; - 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}`} > - {isInSelectionMode ? ( - { - event.stopPropagation(); - onToggleBatch(); - }} - style={[ - styles.checkbox, - isBatchSelected && styles.checkboxSelected, - ]} - > - {isBatchSelected ? ( - - ) : null} - - ) : ( - - )} + ) : null} + + {isBatchSelected ? ( + + + + ) : null} ); } @@ -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 => { @@ -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) => { + ({ item, drag }: DraggableRenderItemInfo) => { 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 ? ( - - Back - - - Archive - + + {selectedBatchKeys.size} selected + + + + + + + + + ) : null} @@ -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, - }, })); diff --git a/packages/app/src/hooks/use-sidebar-agents-list.test.ts b/packages/app/src/hooks/use-sidebar-agents-list.test.ts new file mode 100644 index 000000000..688f69568 --- /dev/null +++ b/packages/app/src/hooks/use-sidebar-agents-list.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/hooks/use-sidebar-agents-list.ts b/packages/app/src/hooks/use-sidebar-agents-list.ts index 76af54cf5..a36588024 100644 --- a/packages/app/src/hooks/use-sidebar-agents-list.ts +++ b/packages/app/src/hooks/use-sidebar-agents-list.ts @@ -68,69 +68,85 @@ function toSidebarAgentKey(entry: SidebarAgentListEntry): string { return `${entry.agent.serverId}:${entry.agent.id}`; } -function applySidebarUserOrdering(input: { +export function applySidebarUserOrdering(input: { entries: SidebarAgentListEntry[]; order: string[]; }): { entries: SidebarAgentListEntry[]; hasMore: boolean } { - const entryKeySet = new Set(input.entries.map((entry) => toSidebarAgentKey(entry))); - const prunedOrder = input.order.filter((key) => entryKeySet.has(key)); - const knownOrderSet = new Set(prunedOrder); - const newEntries = input.entries - .filter((entry) => !knownOrderSet.has(toSidebarAgentKey(entry))) - .sort(compareByCreatedAtDesc); - const effectiveOrder = [ - ...newEntries.map((entry) => toSidebarAgentKey(entry)), - ...prunedOrder, - ]; - const orderIndexByKey = new Map(); - for (let index = 0; index < effectiveOrder.length; index += 1) { - orderIndexByKey.set(effectiveOrder[index] ?? "", index); + const sortedByCreatedAt = [...input.entries].sort(compareByCreatedAtDesc); + const entryByKey = new Map(); + for (const entry of sortedByCreatedAt) { + entryByKey.set(toSidebarAgentKey(entry), entry); } - const sorted = [...input.entries].sort((left, right) => { - const leftOrder = orderIndexByKey.get(toSidebarAgentKey(left)); - const rightOrder = orderIndexByKey.get(toSidebarAgentKey(right)); - - 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); + const prunedOrder: string[] = []; + const seenOrderKeys = new Set(); + for (const key of input.order) { + if (!entryByKey.has(key) || seenOrderKeys.has(key)) { continue; } - done.push(entry); + seenOrderKeys.add(key); + prunedOrder.push(key); } - if (active.length >= SIDEBAR_DONE_FILL_TARGET) { - return { - entries: active, - hasMore: done.length > 0, - }; + const sorted: SidebarAgentListEntry[] = []; + if (prunedOrder.length === 0) { + sorted.push(...sortedByCreatedAt); + } 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 { - entries: [...active, ...shownDone], - hasMore: done.length > shownDone.length, + entries: visible, + hasMore: hiddenDoneCount > 0, }; }