Simplify project filter to single selection and persist sidebar order

This commit is contained in:
Mohamed Boudra
2026-03-01 11:26:56 +07:00
parent 3a6a8cfa38
commit 2566171b4c
4 changed files with 386 additions and 306 deletions
+85 -13
View File
@@ -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<View>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const projectFilterAnchorRef = useRef<View>(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<string | null>(
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) {
</>
)}
</Pressable>
{projectFilterOptions.length > 0 ? (
<Pressable
ref={projectFilterAnchorRef}
style={styles.filterButton}
onPress={() => setIsProjectFilterOpen(true)}
>
{({ hovered }) => (
<>
<ListFilter
size={theme.iconSize.md}
color={
(selectedProjectFilterKey || hovered)
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
{selectedProjectFilterKey ? <View style={styles.filterActiveDot} /> : null}
</>
)}
</Pressable>
) : null}
</View>
</View>
@@ -402,10 +425,14 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
) : (
<SidebarAgentList
isOpen={isOpen}
serverId={activeServerId}
entries={entries}
projectFilterOptions={projectFilterOptions}
selectedProjectFilterKeys={selectedProjectFilterKeys}
onSelectedProjectFilterKeysChange={setSelectedProjectFilterKeys}
selectedProjectFilterKey={selectedProjectFilterKey}
onSelectedProjectFilterKeyChange={setSelectedProjectFilterKey}
isProjectFilterOpen={isProjectFilterOpen}
onProjectFilterOpenChange={setIsProjectFilterOpen}
projectFilterAnchorRef={projectFilterAnchorRef}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
@@ -500,8 +527,11 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
return (
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
{trafficLightPadding.top > 0 ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
) : null}
<View
style={[styles.sidebarHeader, { paddingLeft: theme.spacing[2] + trafficLightPadding.left }]}
style={styles.sidebarHeader}
{...dragHandlers}
>
<View style={styles.sidebarHeaderRow}>
@@ -516,10 +546,31 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
</>
)}
</Pressable>
{projectFilterOptions.length > 0 ? (
<Pressable
ref={projectFilterAnchorRef}
style={styles.filterButton}
onPress={() => setIsProjectFilterOpen(true)}
>
{({ hovered }) => (
<>
<ListFilter
size={theme.iconSize.md}
color={
(selectedProjectFilterKey || hovered)
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
{selectedProjectFilterKey ? <View style={styles.filterActiveDot} /> : null}
</>
)}
</Pressable>
) : null}
</View>
</View>
@@ -529,10 +580,14 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
) : (
<SidebarAgentList
isOpen={isOpen}
serverId={activeServerId}
entries={entries}
projectFilterOptions={projectFilterOptions}
selectedProjectFilterKeys={selectedProjectFilterKeys}
onSelectedProjectFilterKeysChange={setSelectedProjectFilterKeys}
selectedProjectFilterKey={selectedProjectFilterKey}
onSelectedProjectFilterKeyChange={setSelectedProjectFilterKey}
isProjectFilterOpen={isProjectFilterOpen}
onProjectFilterOpenChange={setIsProjectFilterOpen}
projectFilterAnchorRef={projectFilterAnchorRef}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
@@ -655,7 +710,7 @@ const styles = StyleSheet.create((theme) => ({
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,
+107 -228
View File
@@ -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<View | null>;
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 (
<Pressable
style={({ pressed, hovered = false }) => [
styles.filterOption,
selected && styles.filterOptionSelected,
hovered && styles.filterOptionHovered,
pressed && styles.filterOptionPressed,
]}
onPress={() => onToggle(option.projectKey)}
onPress={() => onSelect(option.projectKey)}
>
<View style={styles.filterOptionLeft}>
{iconDataUri ? (
@@ -128,7 +132,6 @@ function ProjectFilterOptionRow({
{option.activeCount > 0 ? (
<Text style={styles.filterOptionCount}>{option.activeCount}</Text>
) : null}
{selected ? <Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> : null}
</View>
</Pressable>
);
@@ -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<View>(null);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedBatchKeys, setSelectedBatchKeys] = useState<Set<string>>(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 (
<View style={styles.container}>
{showProjectFilters ? (
<View style={styles.filtersRow}>
<Pressable
ref={projectFilterAnchorRef}
style={({ hovered = false, pressed }) => [
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 ? (
<Image
source={{
uri: selectedProjectIconUri,
}}
style={styles.selectedProjectIcon}
/>
) : selectedProjectFilterKeys.length > 1 ? (
<View style={styles.projectCountBadge}>
<Text style={styles.projectCountBadgeText}>
{selectedProjectFilterKeys.length}
</Text>
</View>
) : null}
<Text
style={[
styles.filterTriggerText,
!showActiveForeground && styles.filterTriggerTextMuted,
]}
numberOfLines={1}
>
{selectedProjectLabel}
</Text>
<ChevronDown
size={theme.iconSize.sm}
color={
showActiveForeground
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
</>
);
}}
</Pressable>
{selectedProjectFilterKeys.length > 0 ? (
<Pressable style={styles.clearFilterButton} onPress={handleClearProjectFilter}>
<Text style={styles.clearFilterText}>Clear</Text>
<Combobox
options={[]}
value=""
onSelect={() => {}}
title="Filter by project"
placeholder="Search projects"
searchPlaceholder="Search projects"
desktopPlacement="bottom-start"
open={isProjectFilterOpen}
onOpenChange={onProjectFilterOpenChange}
anchorRef={projectFilterAnchorRef}
>
<View style={styles.filterOptionsList}>
<Pressable
style={({ pressed, hovered = false }) => [
styles.filterOption,
!selectedProjectFilterKey && styles.filterOptionSelected,
hovered && styles.filterOptionHovered,
pressed && styles.filterOptionPressed,
]}
onPress={handleClearProjectFilter}
>
<Text style={styles.filterOptionLabel}>All projects</Text>
</Pressable>
) : null}
<Combobox
options={[]}
value=""
onSelect={() => {}}
title="Filter by project"
placeholder="Search projects"
searchPlaceholder="Search projects"
desktopPlacement="bottom-start"
open={isProjectFilterOpen}
onOpenChange={setIsProjectFilterOpen}
anchorRef={projectFilterAnchorRef}
>
<View style={styles.filterOptionsList}>
{projectFilterOptions.length === 0 ? (
<Text style={styles.filterEmptyText}>No projects</Text>
) : (
projectFilterOptions.map((option) => (
<ProjectFilterOptionRow
key={option.projectKey}
option={option}
selected={selectedProjectFilterKeys.includes(option.projectKey)}
iconDataUri={projectIconByProjectKey.get(option.projectKey) ?? null}
displayName={deriveProjectDisplayName({
projectKey: option.projectKey,
projectName: option.projectName,
remoteUrl: null,
})}
onToggle={handleToggleProject}
/>
))
)}
</View>
</Combobox>
</View>
{projectFilterOptions.length === 0 ? (
<Text style={styles.filterEmptyText}>No projects</Text>
) : (
projectFilterOptions.map((option) => (
<ProjectFilterOptionRow
key={option.projectKey}
option={option}
selected={selectedProjectFilterKey === option.projectKey}
iconDataUri={projectIconByProjectKey.get(option.projectKey) ?? null}
displayName={deriveProjectDisplayName({
projectKey: option.projectKey,
projectName: option.projectName,
remoteUrl: null,
})}
onSelect={handleSelectProject}
/>
))
)}
</View>
</Combobox>
) : null}
<DraggableList
@@ -867,7 +801,7 @@ export function SidebarAgentList({
testID="sidebar-agent-list-scroll"
keyExtractor={keyExtractor}
renderItem={renderRow}
onDragEnd={() => {}}
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",
@@ -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<string, number>();
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") {
@@ -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<string, string[]>;
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<string>();
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<SidebarOrderStoreState>()(
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,
}),
}
)
);