diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 9b2f56036..e8410b43f 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -16,7 +16,7 @@ import Animated, { } from "react-native-reanimated"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { Plus, Settings, Users } from "lucide-react-native"; +import { Plus, ListFilter, Settings, Users } from "lucide-react-native"; import { router, usePathname } from "expo-router"; import { usePanelStore } from "@/stores/panel-store"; import { SidebarAgentList } from "./sidebar-agent-list"; @@ -119,12 +119,14 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { ); const hostTriggerRef = useRef(null); const [isHostPickerOpen, setIsHostPickerOpen] = useState(false); + const projectFilterAnchorRef = useRef(null); + const [isProjectFilterOpen, setIsProjectFilterOpen] = useState(false); // Derive isOpen from the unified panel state const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; - const [selectedProjectFilterKeys, setSelectedProjectFilterKeys] = useState< - string[] - >([]); + const [selectedProjectFilterKey, setSelectedProjectFilterKey] = useState( + null + ); const { entries, @@ -135,10 +137,10 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { refreshAll, } = useSidebarAgentsList({ serverId: activeServerId, - selectedProjectFilterKeys, + selectedProjectFilterKey, }); useEffect(() => { - setSelectedProjectFilterKeys([]); + setSelectedProjectFilterKey(null); }, [activeServerId]); const { translateX, @@ -393,6 +395,27 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { )} + {projectFilterOptions.length > 0 ? ( + setIsProjectFilterOpen(true)} + > + {({ hovered }) => ( + <> + + {selectedProjectFilterKey ? : null} + + )} + + ) : null} @@ -402,10 +425,14 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { ) : ( + {trafficLightPadding.top > 0 ? ( + + ) : null} @@ -516,10 +546,31 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { size={theme.iconSize.md} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} /> - New agent + New agent )} + {projectFilterOptions.length > 0 ? ( + setIsProjectFilterOpen(true)} + > + {({ hovered }) => ( + <> + + {selectedProjectFilterKey ? : null} + + )} + + ) : null} @@ -529,10 +580,14 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { ) : ( ({ sidebarHeaderRow: { flexDirection: "row", alignItems: "center", - justifyContent: "flex-start", + justifyContent: "space-between", gap: theme.spacing[2], }, newAgentButton: { @@ -666,6 +721,23 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[1], flexShrink: 0, }, + filterButton: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[1], + position: "relative", + }, + filterActiveDot: { + width: 8, + height: 8, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.borderAccent, + position: "absolute", + top: 0, + right: -2, + }, newAgentButtonHovered: {}, newAgentButtonText: { fontSize: theme.fontSize.base, diff --git a/packages/app/src/components/sidebar-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx index 91bc3f733..84abe2bb0 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, ChevronDown } from "lucide-react-native"; +import { Archive, Check } from "lucide-react-native"; import { DraggableList, type DraggableRenderItemInfo, @@ -55,6 +55,7 @@ import { parseRepoNameFromRemoteUrl } from "@/utils/agent-grouping"; import { formatTimeAgo } from "@/utils/time"; import { isSidebarActiveAgent } from "@/utils/sidebar-agent-state"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; +import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; type EntryData = SidebarAgentListEntry; @@ -69,8 +70,12 @@ interface SidebarAgentListProps { isOpen?: boolean; entries: SidebarAgentListEntry[]; projectFilterOptions: SidebarProjectFilterOption[]; - selectedProjectFilterKeys: string[]; - onSelectedProjectFilterKeysChange: (keys: string[]) => void; + selectedProjectFilterKey: string | null; + onSelectedProjectFilterKeyChange: (key: string | null) => void; + isProjectFilterOpen: boolean; + onProjectFilterOpenChange: (open: boolean) => void; + projectFilterAnchorRef: MutableRefObject; + serverId: string | null; isRefreshing?: boolean; onRefresh?: () => void; selectedAgentId?: string; @@ -85,7 +90,7 @@ interface ProjectFilterOptionRowProps { selected: boolean; iconDataUri: string | null; displayName: string; - onToggle: (projectKey: string) => void; + onSelect: (projectKey: string) => void; } function ProjectFilterOptionRow({ @@ -93,18 +98,17 @@ function ProjectFilterOptionRow({ selected, iconDataUri, displayName, - onToggle, + onSelect, }: ProjectFilterOptionRowProps) { - const { theme } = useUnistyles(); - return ( [ styles.filterOption, + selected && styles.filterOptionSelected, hovered && styles.filterOptionHovered, pressed && styles.filterOptionPressed, ]} - onPress={() => onToggle(option.projectKey)} + onPress={() => onSelect(option.projectKey)} > {iconDataUri ? ( @@ -128,7 +132,6 @@ function ProjectFilterOptionRow({ {option.activeCount > 0 ? ( {option.activeCount} ) : null} - {selected ? : null} ); @@ -390,33 +393,6 @@ function deriveShortcutIndexByAgentKey(sidebarShortcutAgentKeys: string[]) { return map; } -function resolveSelectedProjectLabel(input: { - selectedProjectFilterKeys: string[]; - projectFilterOptions: SidebarProjectFilterOption[]; -}): string { - if (input.selectedProjectFilterKeys.length === 0) { - return "Project"; - } - if (input.selectedProjectFilterKeys.length === 1) { - const selected = input.projectFilterOptions.find( - (option) => option.projectKey === input.selectedProjectFilterKeys[0] - ); - if (selected) { - return deriveProjectDisplayName({ - projectKey: selected.projectKey, - projectName: selected.projectName, - remoteUrl: null, - }); - } - return deriveProjectDisplayName({ - projectKey: input.selectedProjectFilterKeys[0] ?? "", - projectName: "", - remoteUrl: null, - }); - } - return "Projects"; -} - function deriveProjectDisplayName(input: { projectKey: string; projectName: string; @@ -456,8 +432,12 @@ export function SidebarAgentList({ isOpen = true, entries, projectFilterOptions, - selectedProjectFilterKeys, - onSelectedProjectFilterKeysChange, + selectedProjectFilterKey, + onSelectedProjectFilterKeyChange, + isProjectFilterOpen, + onProjectFilterOpenChange, + projectFilterAnchorRef, + serverId, isRefreshing = false, onRefresh, selectedAgentId, @@ -470,12 +450,13 @@ export function SidebarAgentList({ const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; - const [isProjectFilterOpen, setIsProjectFilterOpen] = useState(false); - const projectFilterAnchorRef = useRef(null); const [isSelectionMode, setIsSelectionMode] = useState(false); const [selectedBatchKeys, setSelectedBatchKeys] = useState>(new Set()); const { archiveAgent, isArchivingAgent } = useArchiveAgent(); + const getSidebarOrder = useSidebarOrderStore((state) => state.getOrder); + const setSidebarOrder = useSidebarOrderStore((state) => state.setOrder); + const removeSidebarOrderEntry = useSidebarOrderStore((state) => state.remove); const altDown = useKeyboardShortcutsStore((s) => s.altDown); const cmdOrCtrlDown = useKeyboardShortcutsStore((s) => s.cmdOrCtrlDown); @@ -489,19 +470,6 @@ export function SidebarAgentList({ [sidebarShortcutAgentKeys] ); - const selectedProjectLabel = useMemo( - () => resolveSelectedProjectLabel({ selectedProjectFilterKeys, projectFilterOptions }), - [projectFilterOptions, selectedProjectFilterKeys] - ); - - const selectedProjectOption = useMemo(() => { - if (selectedProjectFilterKeys.length !== 1) { - return null; - } - return ( - projectFilterOptions.find((option) => option.projectKey === selectedProjectFilterKeys[0]) ?? null - ); - }, [projectFilterOptions, selectedProjectFilterKeys]); const showProjectFilters = projectFilterOptions.length > 0; const projectIconRequests = useMemo(() => { @@ -592,29 +560,18 @@ export function SidebarAgentList({ return map; }, [entries, projectIconByQueryKey, projectFilterOptions]); - const selectedProjectIconUri = useMemo(() => { - if (!selectedProjectOption) { - return null; - } - return projectIconByProjectKey.get(selectedProjectOption.projectKey) ?? null; - }, [projectIconByProjectKey, selectedProjectOption]); - - const handleToggleProject = useCallback( + const handleSelectProject = useCallback( (projectKey: string) => { - const next = new Set(selectedProjectFilterKeys); - if (next.has(projectKey)) { - next.delete(projectKey); - } else { - next.add(projectKey); - } - onSelectedProjectFilterKeysChange(Array.from(next)); + onSelectedProjectFilterKeyChange( + selectedProjectFilterKey === projectKey ? null : projectKey + ); }, - [onSelectedProjectFilterKeysChange, selectedProjectFilterKeys] + [onSelectedProjectFilterKeyChange, selectedProjectFilterKey] ); const handleClearProjectFilter = useCallback(() => { - onSelectedProjectFilterKeysChange([]); - }, [onSelectedProjectFilterKeysChange]); + onSelectedProjectFilterKeyChange(null); + }, [onSelectedProjectFilterKeyChange]); const handleAgentPress = useCallback( (entry: SidebarAgentListEntry) => { @@ -659,11 +616,18 @@ export function SidebarAgentList({ await archiveAgent({ serverId: entry.agent.serverId, agentId: entry.agent.id, - }).catch((error) => { - console.warn("[archive_agent] failed", error); - }); + }) + .then(() => { + removeSidebarOrderEntry( + entry.agent.serverId, + `${entry.agent.serverId}:${entry.agent.id}` + ); + }) + .catch((error) => { + console.warn("[archive_agent] failed", error); + }); }, - [archiveAgent] + [archiveAgent, removeSidebarOrderEntry] ); const handleArchiveBatch = useCallback(() => { @@ -682,9 +646,13 @@ export function SidebarAgentList({ archiveAgent({ serverId: parsed.serverId, agentId: parsed.agentId, - }).catch((error) => { - console.warn("[archive_agent_batch] failed", { key, error }); }) + .then(() => { + removeSidebarOrderEntry(parsed.serverId, key); + }) + .catch((error) => { + console.warn("[archive_agent_batch] failed", { key, error }); + }) ); } @@ -692,7 +660,7 @@ export function SidebarAgentList({ setSelectedBatchKeys(new Set()); setIsSelectionMode(false); }); - }, [archiveAgent, selectedBatchKeys]); + }, [archiveAgent, removeSidebarOrderEntry, selectedBatchKeys]); const handleSelectionBack = useCallback(() => { setSelectedBatchKeys(new Set()); @@ -759,102 +727,68 @@ export function SidebarAgentList({ (entry: EntryData) => `${entry.agent.serverId}:${entry.agent.id}`, [] ); + const handleDragEnd = useCallback( + (reorderedEntries: SidebarAgentListEntry[]) => { + if (!serverId) { + return; + } + const reorderedKeys = reorderedEntries.map( + (entry) => `${entry.agent.serverId}:${entry.agent.id}` + ); + const reorderedSet = new Set(reorderedKeys); + const remainder = getSidebarOrder(serverId).filter((key) => !reorderedSet.has(key)); + setSidebarOrder(serverId, [...reorderedKeys, ...remainder]); + }, + [getSidebarOrder, serverId, setSidebarOrder] + ); return ( {showProjectFilters ? ( - - [ - styles.filterTrigger, - (selectedProjectFilterKeys.length > 0 || hovered || pressed) && - styles.filterTriggerActive, - ]} - onPress={() => setIsProjectFilterOpen(true)} - > - {({ hovered = false, pressed }) => { - const isInteracting = hovered || pressed; - const showActiveForeground = - selectedProjectFilterKeys.length > 0 || isInteracting; - return ( - <> - {selectedProjectFilterKeys.length === 1 && selectedProjectIconUri ? ( - - ) : selectedProjectFilterKeys.length > 1 ? ( - - - {selectedProjectFilterKeys.length} - - - ) : null} - - {selectedProjectLabel} - - - - ); - }} - - - {selectedProjectFilterKeys.length > 0 ? ( - - Clear + {}} + title="Filter by project" + placeholder="Search projects" + searchPlaceholder="Search projects" + desktopPlacement="bottom-start" + open={isProjectFilterOpen} + onOpenChange={onProjectFilterOpenChange} + anchorRef={projectFilterAnchorRef} + > + + [ + styles.filterOption, + !selectedProjectFilterKey && styles.filterOptionSelected, + hovered && styles.filterOptionHovered, + pressed && styles.filterOptionPressed, + ]} + onPress={handleClearProjectFilter} + > + All projects - ) : null} - - {}} - title="Filter by project" - placeholder="Search projects" - searchPlaceholder="Search projects" - desktopPlacement="bottom-start" - open={isProjectFilterOpen} - onOpenChange={setIsProjectFilterOpen} - anchorRef={projectFilterAnchorRef} - > - - {projectFilterOptions.length === 0 ? ( - No projects - ) : ( - projectFilterOptions.map((option) => ( - - )) - )} - - - + {projectFilterOptions.length === 0 ? ( + No projects + ) : ( + projectFilterOptions.map((option) => ( + + )) + )} + + ) : null} {}} + onDragEnd={handleDragEnd} showsVerticalScrollIndicator={false} enableDesktopWebScrollbar={showDesktopWebScrollbar} ListFooterComponent={listFooterComponent} @@ -902,64 +836,6 @@ const styles = StyleSheet.create((theme) => ({ container: { flex: 1, }, - filtersRow: { - paddingHorizontal: theme.spacing[3], - paddingTop: theme.spacing[2], - paddingBottom: theme.spacing[2], - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - filterTrigger: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - alignSelf: "flex-start", - maxWidth: 260, - borderWidth: 1, - borderColor: theme.colors.border, - borderRadius: theme.borderRadius.full, - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[1], - backgroundColor: theme.colors.surface1, - }, - filterTriggerActive: { - borderColor: theme.colors.borderAccent, - }, - filterTriggerText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - maxWidth: 180, - }, - filterTriggerTextMuted: { - color: theme.colors.foregroundMuted, - }, - selectedProjectIcon: { - width: theme.iconSize.sm, - height: theme.iconSize.sm, - borderRadius: theme.borderRadius.sm, - }, - projectCountBadge: { - minWidth: theme.iconSize.md, - height: theme.iconSize.md, - borderRadius: theme.borderRadius.full, - alignItems: "center", - justifyContent: "center", - paddingHorizontal: 4, - backgroundColor: theme.colors.surface2, - }, - projectCountBadgeText: { - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - }, - clearFilterButton: { - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1], - }, - clearFilterText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, filterOptionsList: { gap: theme.spacing[1], }, @@ -983,6 +859,9 @@ const styles = StyleSheet.create((theme) => ({ filterOptionPressed: { backgroundColor: theme.colors.surface2, }, + filterOptionSelected: { + backgroundColor: theme.colors.surface2, + }, filterOptionLeft: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/hooks/use-sidebar-agents-list.ts b/packages/app/src/hooks/use-sidebar-agents-list.ts index 36d7ef7d8..76af54cf5 100644 --- a/packages/app/src/hooks/use-sidebar-agents-list.ts +++ b/packages/app/src/hooks/use-sidebar-agents-list.ts @@ -6,13 +6,14 @@ import { } from "@/runtime/host-runtime"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { - deriveSidebarStateBucket, isSidebarActiveAgent, } from "@/utils/sidebar-agent-state"; import type { ProjectPlacementPayload } from "@server/shared/messages"; import { resolveProjectPlacement } from "@/utils/project-placement"; +import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; const SIDEBAR_DONE_FILL_TARGET = 50; +const EMPTY_ORDER: string[] = []; export interface SidebarProjectFilterOption { projectKey: string; @@ -38,25 +39,23 @@ export interface SidebarAgentsListResult { refreshAll: () => void; } -function compareByLastActivityDesc( +function compareByCreatedAtDesc( left: SidebarAgentListEntry, right: SidebarAgentListEntry ): number { - return right.agent.lastActivityAt.getTime() - left.agent.lastActivityAt.getTime(); -} + const createdDelta = right.agent.createdAt.getTime() - left.agent.createdAt.getTime(); + if (createdDelta !== 0) { + return createdDelta; + } -function compareByTitleAsc( - left: SidebarAgentListEntry, - right: SidebarAgentListEntry -): number { const leftTitle = (left.agent.title?.trim() || "New agent").toLocaleLowerCase(); const rightTitle = (right.agent.title?.trim() || "New agent").toLocaleLowerCase(); - const titleCmp = leftTitle.localeCompare(rightTitle, undefined, { + const titleDelta = leftTitle.localeCompare(rightTitle, undefined, { numeric: true, sensitivity: "base", }); - if (titleCmp !== 0) { - return titleCmp; + if (titleDelta !== 0) { + return titleDelta; } return left.agent.id.localeCompare(right.agent.id, undefined, { @@ -65,50 +64,66 @@ function compareByTitleAsc( }); } -function applySidebarDefaultOrdering( - entries: SidebarAgentListEntry[] -): { entries: SidebarAgentListEntry[]; hasMore: boolean } { - const needsInput: SidebarAgentListEntry[] = []; - const failed: SidebarAgentListEntry[] = []; - const running: SidebarAgentListEntry[] = []; - const attention: SidebarAgentListEntry[] = []; - const done: SidebarAgentListEntry[] = []; +function toSidebarAgentKey(entry: SidebarAgentListEntry): string { + return `${entry.agent.serverId}:${entry.agent.id}`; +} - for (const entry of entries) { - const bucket = deriveSidebarStateBucket({ +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 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 (bucket === "needs_input") { - needsInput.push(entry); - continue; - } - if (bucket === "failed") { - failed.push(entry); - continue; - } - if (bucket === "running") { - running.push(entry); - continue; - } - if (bucket === "attention") { - attention.push(entry); + if (isActive) { + active.push(entry); continue; } done.push(entry); } - needsInput.sort(compareByLastActivityDesc); - failed.sort(compareByLastActivityDesc); - running.sort(compareByTitleAsc); - attention.sort(compareByLastActivityDesc); - done.sort(compareByLastActivityDesc); - - const active = [...needsInput, ...failed, ...running, ...attention]; if (active.length >= SIDEBAR_DONE_FILL_TARGET) { - return { entries: active, hasMore: done.length > 0 }; + return { + entries: active, + hasMore: done.length > 0, + }; } const remainingDoneSlots = SIDEBAR_DONE_FILL_TARGET - active.length; @@ -147,7 +162,7 @@ function toAggregatedAgent(params: { export function useSidebarAgentsList(options?: { serverId?: string | null; - selectedProjectFilterKeys?: string[]; + selectedProjectFilterKey?: string | null; }): SidebarAgentsListResult { const { daemons } = useDaemonRegistry(); const runtime = getHostRuntimeStore(); @@ -171,14 +186,16 @@ export function useSidebarAgentsList(options?: { return daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId; }, [daemonLabelSignature, serverId]); - const selectedProjectFilterKeys = useMemo( - () => - new Set( - (options?.selectedProjectFilterKeys ?? []) - .map((item) => item.trim()) - .filter((item) => item.length > 0) - ), - [options?.selectedProjectFilterKeys] + const selectedProjectFilterKey = useMemo(() => { + const value = options?.selectedProjectFilterKey; + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + }, [options?.selectedProjectFilterKey]); + const persistedOrder = useSidebarOrderStore((state) => + serverId ? state.orderByServerId[serverId] ?? EMPTY_ORDER : EMPTY_ORDER ); const isActive = Boolean(serverId); @@ -213,7 +230,12 @@ export function useSidebarAgentsList(options?: { const [connectionStatus = "idle", directoryStatus = "idle"] = runtimeStatusSignature.split(":", 2); - const { entries, projectFilterOptions, hasAnyData, hasMoreEntries } = useMemo(() => { + const { + entries, + projectFilterOptions, + hasAnyData, + hasMoreEntries, + } = useMemo(() => { if (!isActive || !serverId || !liveAgents) { return { entries: [] as SidebarAgentListEntry[], @@ -282,14 +304,15 @@ export function useSidebarAgentsList(options?: { pushEntry({ agent, project }); } - const filteredEntries = - selectedProjectFilterKeys.size > 0 - ? mergedEntries.filter((entry) => - selectedProjectFilterKeys.has(entry.project.projectKey) - ) - : mergedEntries; - - const ordered = applySidebarDefaultOrdering(filteredEntries); + const filteredEntries = selectedProjectFilterKey + ? mergedEntries.filter( + (entry) => entry.project.projectKey === selectedProjectFilterKey + ) + : mergedEntries; + const ordered = applySidebarUserOrdering({ + entries: filteredEntries, + order: persistedOrder, + }); const options = Array.from(byProject.values()).sort((left, right) => { if (left.activeCount !== right.activeCount) { return right.activeCount - left.activeCount; @@ -297,14 +320,21 @@ export function useSidebarAgentsList(options?: { return left.projectName.localeCompare(right.projectName); }); - const result = { + return { entries: ordered.entries, projectFilterOptions: options, - hasAnyData: ordered.entries.length > 0, + hasAnyData: mergedEntries.length > 0, hasMoreEntries: ordered.hasMore, }; - return result; - }, [agentLastActivity, isActive, liveAgents, selectedProjectFilterKeys, serverId, serverLabel]); + }, [ + agentLastActivity, + isActive, + liveAgents, + persistedOrder, + selectedProjectFilterKey, + serverId, + serverLabel, + ]); const refreshAll = useCallback(() => { if (!isActive || !serverId || connectionStatus !== "online") { diff --git a/packages/app/src/stores/sidebar-order-store.ts b/packages/app/src/stores/sidebar-order-store.ts new file mode 100644 index 000000000..eaf08c56f --- /dev/null +++ b/packages/app/src/stores/sidebar-order-store.ts @@ -0,0 +1,99 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +interface SidebarOrderStoreState { + orderByServerId: Record; + getOrder: (serverId: string) => string[]; + setOrder: (serverId: string, keys: string[]) => void; + insertAtTop: (serverId: string, key: string) => void; + remove: (serverId: string, key: string) => void; +} + +function normalizeKeys(keys: string[]): string[] { + const seen = new Set(); + const normalized: string[] = []; + + for (const rawKey of keys) { + const key = rawKey.trim(); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + normalized.push(key); + } + + return normalized; +} + +export const useSidebarOrderStore = create()( + persist( + (set, get) => ({ + orderByServerId: {}, + getOrder: (serverId) => { + const key = serverId.trim(); + if (!key) { + return []; + } + return get().orderByServerId[key] ?? []; + }, + setOrder: (serverId, keys) => { + const key = serverId.trim(); + if (!key) { + return; + } + const normalized = normalizeKeys(keys); + set((state) => ({ + orderByServerId: { + ...state.orderByServerId, + [key]: normalized, + }, + })); + }, + insertAtTop: (serverId, rawKey) => { + const serverKey = serverId.trim(); + const entryKey = rawKey.trim(); + if (!serverKey || !entryKey) { + return; + } + set((state) => { + const current = state.orderByServerId[serverKey] ?? []; + const next = [entryKey, ...current.filter((key) => key !== entryKey)]; + return { + orderByServerId: { + ...state.orderByServerId, + [serverKey]: next, + }, + }; + }); + }, + remove: (serverId, rawKey) => { + const serverKey = serverId.trim(); + const entryKey = rawKey.trim(); + if (!serverKey || !entryKey) { + return; + } + set((state) => { + const current = state.orderByServerId[serverKey] ?? []; + const next = current.filter((key) => key !== entryKey); + if (next.length === current.length) { + return state; + } + return { + orderByServerId: { + ...state.orderByServerId, + [serverKey]: next, + }, + }; + }); + }, + }), + { + name: "sidebar-agent-order", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + orderByServerId: state.orderByServerId, + }), + } + ) +);