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