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"; } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; 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 { router, usePathname } from "expo-router";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { SidebarAgentList } from "./sidebar-agent-list"; import { SidebarAgentList } from "./sidebar-agent-list";
@@ -119,12 +119,14 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
); );
const hostTriggerRef = useRef<View>(null); const hostTriggerRef = useRef<View>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false); const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const projectFilterAnchorRef = useRef<View>(null);
const [isProjectFilterOpen, setIsProjectFilterOpen] = useState(false);
// Derive isOpen from the unified panel state // Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const [selectedProjectFilterKeys, setSelectedProjectFilterKeys] = useState< const [selectedProjectFilterKey, setSelectedProjectFilterKey] = useState<string | null>(
string[] null
>([]); );
const { const {
entries, entries,
@@ -135,10 +137,10 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
refreshAll, refreshAll,
} = useSidebarAgentsList({ } = useSidebarAgentsList({
serverId: activeServerId, serverId: activeServerId,
selectedProjectFilterKeys, selectedProjectFilterKey,
}); });
useEffect(() => { useEffect(() => {
setSelectedProjectFilterKeys([]); setSelectedProjectFilterKey(null);
}, [activeServerId]); }, [activeServerId]);
const { const {
translateX, translateX,
@@ -393,6 +395,27 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
</> </>
)} )}
</Pressable> </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>
</View> </View>
@@ -402,10 +425,14 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
) : ( ) : (
<SidebarAgentList <SidebarAgentList
isOpen={isOpen} isOpen={isOpen}
serverId={activeServerId}
entries={entries} entries={entries}
projectFilterOptions={projectFilterOptions} projectFilterOptions={projectFilterOptions}
selectedProjectFilterKeys={selectedProjectFilterKeys} selectedProjectFilterKey={selectedProjectFilterKey}
onSelectedProjectFilterKeysChange={setSelectedProjectFilterKeys} onSelectedProjectFilterKeyChange={setSelectedProjectFilterKey}
isProjectFilterOpen={isProjectFilterOpen}
onProjectFilterOpenChange={setIsProjectFilterOpen}
projectFilterAnchorRef={projectFilterAnchorRef}
isRefreshing={isManualRefresh && isRevalidating} isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh} onRefresh={handleRefresh}
listFooterComponent={listFooterComponent} listFooterComponent={listFooterComponent}
@@ -500,8 +527,11 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
return ( return (
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}> <View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
{trafficLightPadding.top > 0 ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
) : null}
<View <View
style={[styles.sidebarHeader, { paddingLeft: theme.spacing[2] + trafficLightPadding.left }]} style={styles.sidebarHeader}
{...dragHandlers} {...dragHandlers}
> >
<View style={styles.sidebarHeaderRow}> <View style={styles.sidebarHeaderRow}>
@@ -516,10 +546,31 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
size={theme.iconSize.md} size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} 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> </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>
</View> </View>
@@ -529,10 +580,14 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
) : ( ) : (
<SidebarAgentList <SidebarAgentList
isOpen={isOpen} isOpen={isOpen}
serverId={activeServerId}
entries={entries} entries={entries}
projectFilterOptions={projectFilterOptions} projectFilterOptions={projectFilterOptions}
selectedProjectFilterKeys={selectedProjectFilterKeys} selectedProjectFilterKey={selectedProjectFilterKey}
onSelectedProjectFilterKeysChange={setSelectedProjectFilterKeys} onSelectedProjectFilterKeyChange={setSelectedProjectFilterKey}
isProjectFilterOpen={isProjectFilterOpen}
onProjectFilterOpenChange={setIsProjectFilterOpen}
projectFilterAnchorRef={projectFilterAnchorRef}
isRefreshing={isManualRefresh && isRevalidating} isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh} onRefresh={handleRefresh}
listFooterComponent={listFooterComponent} listFooterComponent={listFooterComponent}
@@ -655,7 +710,7 @@ const styles = StyleSheet.create((theme) => ({
sidebarHeaderRow: { sidebarHeaderRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "flex-start", justifyContent: "space-between",
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
newAgentButton: { newAgentButton: {
@@ -666,6 +721,23 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[1], paddingHorizontal: theme.spacing[1],
flexShrink: 0, 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: {}, newAgentButtonHovered: {},
newAgentButtonText: { newAgentButtonText: {
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
+107 -228
View File
@@ -23,7 +23,7 @@ import {
useUnistyles, useUnistyles,
} from "react-native-unistyles"; } from "react-native-unistyles";
import { type GestureType } from "react-native-gesture-handler"; import { type GestureType } from "react-native-gesture-handler";
import { Archive, Check, ChevronDown } from "lucide-react-native"; import { Archive, Check } from "lucide-react-native";
import { import {
DraggableList, DraggableList,
type DraggableRenderItemInfo, type DraggableRenderItemInfo,
@@ -55,6 +55,7 @@ import { parseRepoNameFromRemoteUrl } from "@/utils/agent-grouping";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { isSidebarActiveAgent } from "@/utils/sidebar-agent-state"; import { isSidebarActiveAgent } from "@/utils/sidebar-agent-state";
import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
type EntryData = SidebarAgentListEntry; type EntryData = SidebarAgentListEntry;
@@ -69,8 +70,12 @@ interface SidebarAgentListProps {
isOpen?: boolean; isOpen?: boolean;
entries: SidebarAgentListEntry[]; entries: SidebarAgentListEntry[];
projectFilterOptions: SidebarProjectFilterOption[]; projectFilterOptions: SidebarProjectFilterOption[];
selectedProjectFilterKeys: string[]; selectedProjectFilterKey: string | null;
onSelectedProjectFilterKeysChange: (keys: string[]) => void; onSelectedProjectFilterKeyChange: (key: string | null) => void;
isProjectFilterOpen: boolean;
onProjectFilterOpenChange: (open: boolean) => void;
projectFilterAnchorRef: MutableRefObject<View | null>;
serverId: string | null;
isRefreshing?: boolean; isRefreshing?: boolean;
onRefresh?: () => void; onRefresh?: () => void;
selectedAgentId?: string; selectedAgentId?: string;
@@ -85,7 +90,7 @@ interface ProjectFilterOptionRowProps {
selected: boolean; selected: boolean;
iconDataUri: string | null; iconDataUri: string | null;
displayName: string; displayName: string;
onToggle: (projectKey: string) => void; onSelect: (projectKey: string) => void;
} }
function ProjectFilterOptionRow({ function ProjectFilterOptionRow({
@@ -93,18 +98,17 @@ function ProjectFilterOptionRow({
selected, selected,
iconDataUri, iconDataUri,
displayName, displayName,
onToggle, onSelect,
}: ProjectFilterOptionRowProps) { }: ProjectFilterOptionRowProps) {
const { theme } = useUnistyles();
return ( return (
<Pressable <Pressable
style={({ pressed, hovered = false }) => [ style={({ pressed, hovered = false }) => [
styles.filterOption, styles.filterOption,
selected && styles.filterOptionSelected,
hovered && styles.filterOptionHovered, hovered && styles.filterOptionHovered,
pressed && styles.filterOptionPressed, pressed && styles.filterOptionPressed,
]} ]}
onPress={() => onToggle(option.projectKey)} onPress={() => onSelect(option.projectKey)}
> >
<View style={styles.filterOptionLeft}> <View style={styles.filterOptionLeft}>
{iconDataUri ? ( {iconDataUri ? (
@@ -128,7 +132,6 @@ function ProjectFilterOptionRow({
{option.activeCount > 0 ? ( {option.activeCount > 0 ? (
<Text style={styles.filterOptionCount}>{option.activeCount}</Text> <Text style={styles.filterOptionCount}>{option.activeCount}</Text>
) : null} ) : null}
{selected ? <Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> : null}
</View> </View>
</Pressable> </Pressable>
); );
@@ -390,33 +393,6 @@ function deriveShortcutIndexByAgentKey(sidebarShortcutAgentKeys: string[]) {
return map; 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: { function deriveProjectDisplayName(input: {
projectKey: string; projectKey: string;
projectName: string; projectName: string;
@@ -456,8 +432,12 @@ export function SidebarAgentList({
isOpen = true, isOpen = true,
entries, entries,
projectFilterOptions, projectFilterOptions,
selectedProjectFilterKeys, selectedProjectFilterKey,
onSelectedProjectFilterKeysChange, onSelectedProjectFilterKeyChange,
isProjectFilterOpen,
onProjectFilterOpenChange,
projectFilterAnchorRef,
serverId,
isRefreshing = false, isRefreshing = false,
onRefresh, onRefresh,
selectedAgentId, selectedAgentId,
@@ -470,12 +450,13 @@ export function SidebarAgentList({
const isMobile = const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const [isProjectFilterOpen, setIsProjectFilterOpen] = useState(false);
const projectFilterAnchorRef = useRef<View>(null);
const [isSelectionMode, setIsSelectionMode] = useState(false); const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedBatchKeys, setSelectedBatchKeys] = useState<Set<string>>(new Set()); const [selectedBatchKeys, setSelectedBatchKeys] = useState<Set<string>>(new Set());
const { archiveAgent, isArchivingAgent } = useArchiveAgent(); 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 altDown = useKeyboardShortcutsStore((s) => s.altDown);
const cmdOrCtrlDown = useKeyboardShortcutsStore((s) => s.cmdOrCtrlDown); const cmdOrCtrlDown = useKeyboardShortcutsStore((s) => s.cmdOrCtrlDown);
@@ -489,19 +470,6 @@ export function SidebarAgentList({
[sidebarShortcutAgentKeys] [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 showProjectFilters = projectFilterOptions.length > 0;
const projectIconRequests = useMemo(() => { const projectIconRequests = useMemo(() => {
@@ -592,29 +560,18 @@ export function SidebarAgentList({
return map; return map;
}, [entries, projectIconByQueryKey, projectFilterOptions]); }, [entries, projectIconByQueryKey, projectFilterOptions]);
const selectedProjectIconUri = useMemo(() => { const handleSelectProject = useCallback(
if (!selectedProjectOption) {
return null;
}
return projectIconByProjectKey.get(selectedProjectOption.projectKey) ?? null;
}, [projectIconByProjectKey, selectedProjectOption]);
const handleToggleProject = useCallback(
(projectKey: string) => { (projectKey: string) => {
const next = new Set(selectedProjectFilterKeys); onSelectedProjectFilterKeyChange(
if (next.has(projectKey)) { selectedProjectFilterKey === projectKey ? null : projectKey
next.delete(projectKey); );
} else {
next.add(projectKey);
}
onSelectedProjectFilterKeysChange(Array.from(next));
}, },
[onSelectedProjectFilterKeysChange, selectedProjectFilterKeys] [onSelectedProjectFilterKeyChange, selectedProjectFilterKey]
); );
const handleClearProjectFilter = useCallback(() => { const handleClearProjectFilter = useCallback(() => {
onSelectedProjectFilterKeysChange([]); onSelectedProjectFilterKeyChange(null);
}, [onSelectedProjectFilterKeysChange]); }, [onSelectedProjectFilterKeyChange]);
const handleAgentPress = useCallback( const handleAgentPress = useCallback(
(entry: SidebarAgentListEntry) => { (entry: SidebarAgentListEntry) => {
@@ -659,11 +616,18 @@ export function SidebarAgentList({
await archiveAgent({ await archiveAgent({
serverId: entry.agent.serverId, serverId: entry.agent.serverId,
agentId: entry.agent.id, 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(() => { const handleArchiveBatch = useCallback(() => {
@@ -682,9 +646,13 @@ export function SidebarAgentList({
archiveAgent({ archiveAgent({
serverId: parsed.serverId, serverId: parsed.serverId,
agentId: parsed.agentId, 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()); setSelectedBatchKeys(new Set());
setIsSelectionMode(false); setIsSelectionMode(false);
}); });
}, [archiveAgent, selectedBatchKeys]); }, [archiveAgent, removeSidebarOrderEntry, selectedBatchKeys]);
const handleSelectionBack = useCallback(() => { const handleSelectionBack = useCallback(() => {
setSelectedBatchKeys(new Set()); setSelectedBatchKeys(new Set());
@@ -759,102 +727,68 @@ export function SidebarAgentList({
(entry: EntryData) => `${entry.agent.serverId}:${entry.agent.id}`, (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 ( return (
<View style={styles.container}> <View style={styles.container}>
{showProjectFilters ? ( {showProjectFilters ? (
<View style={styles.filtersRow}> <Combobox
<Pressable options={[]}
ref={projectFilterAnchorRef} value=""
style={({ hovered = false, pressed }) => [ onSelect={() => {}}
styles.filterTrigger, title="Filter by project"
(selectedProjectFilterKeys.length > 0 || hovered || pressed) && placeholder="Search projects"
styles.filterTriggerActive, searchPlaceholder="Search projects"
]} desktopPlacement="bottom-start"
onPress={() => setIsProjectFilterOpen(true)} open={isProjectFilterOpen}
> onOpenChange={onProjectFilterOpenChange}
{({ hovered = false, pressed }) => { anchorRef={projectFilterAnchorRef}
const isInteracting = hovered || pressed; >
const showActiveForeground = <View style={styles.filterOptionsList}>
selectedProjectFilterKeys.length > 0 || isInteracting; <Pressable
return ( style={({ pressed, hovered = false }) => [
<> styles.filterOption,
{selectedProjectFilterKeys.length === 1 && selectedProjectIconUri ? ( !selectedProjectFilterKey && styles.filterOptionSelected,
<Image hovered && styles.filterOptionHovered,
source={{ pressed && styles.filterOptionPressed,
uri: selectedProjectIconUri, ]}
}} onPress={handleClearProjectFilter}
style={styles.selectedProjectIcon} >
/> <Text style={styles.filterOptionLabel}>All projects</Text>
) : 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>
</Pressable> </Pressable>
) : null} {projectFilterOptions.length === 0 ? (
<Text style={styles.filterEmptyText}>No projects</Text>
<Combobox ) : (
options={[]} projectFilterOptions.map((option) => (
value="" <ProjectFilterOptionRow
onSelect={() => {}} key={option.projectKey}
title="Filter by project" option={option}
placeholder="Search projects" selected={selectedProjectFilterKey === option.projectKey}
searchPlaceholder="Search projects" iconDataUri={projectIconByProjectKey.get(option.projectKey) ?? null}
desktopPlacement="bottom-start" displayName={deriveProjectDisplayName({
open={isProjectFilterOpen} projectKey: option.projectKey,
onOpenChange={setIsProjectFilterOpen} projectName: option.projectName,
anchorRef={projectFilterAnchorRef} remoteUrl: null,
> })}
<View style={styles.filterOptionsList}> onSelect={handleSelectProject}
{projectFilterOptions.length === 0 ? ( />
<Text style={styles.filterEmptyText}>No projects</Text> ))
) : ( )}
projectFilterOptions.map((option) => ( </View>
<ProjectFilterOptionRow </Combobox>
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>
) : null} ) : null}
<DraggableList <DraggableList
@@ -867,7 +801,7 @@ export function SidebarAgentList({
testID="sidebar-agent-list-scroll" testID="sidebar-agent-list-scroll"
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
renderItem={renderRow} renderItem={renderRow}
onDragEnd={() => {}} onDragEnd={handleDragEnd}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
enableDesktopWebScrollbar={showDesktopWebScrollbar} enableDesktopWebScrollbar={showDesktopWebScrollbar}
ListFooterComponent={listFooterComponent} ListFooterComponent={listFooterComponent}
@@ -902,64 +836,6 @@ const styles = StyleSheet.create((theme) => ({
container: { container: {
flex: 1, 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: { filterOptionsList: {
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
@@ -983,6 +859,9 @@ const styles = StyleSheet.create((theme) => ({
filterOptionPressed: { filterOptionPressed: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
filterOptionSelected: {
backgroundColor: theme.colors.surface2,
},
filterOptionLeft: { filterOptionLeft: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -6,13 +6,14 @@ import {
} from "@/runtime/host-runtime"; } from "@/runtime/host-runtime";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { import {
deriveSidebarStateBucket,
isSidebarActiveAgent, isSidebarActiveAgent,
} from "@/utils/sidebar-agent-state"; } from "@/utils/sidebar-agent-state";
import type { ProjectPlacementPayload } from "@server/shared/messages"; import type { ProjectPlacementPayload } from "@server/shared/messages";
import { resolveProjectPlacement } from "@/utils/project-placement"; import { resolveProjectPlacement } from "@/utils/project-placement";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
const SIDEBAR_DONE_FILL_TARGET = 50; const SIDEBAR_DONE_FILL_TARGET = 50;
const EMPTY_ORDER: string[] = [];
export interface SidebarProjectFilterOption { export interface SidebarProjectFilterOption {
projectKey: string; projectKey: string;
@@ -38,25 +39,23 @@ export interface SidebarAgentsListResult {
refreshAll: () => void; refreshAll: () => void;
} }
function compareByLastActivityDesc( function compareByCreatedAtDesc(
left: SidebarAgentListEntry, left: SidebarAgentListEntry,
right: SidebarAgentListEntry right: SidebarAgentListEntry
): number { ): 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 leftTitle = (left.agent.title?.trim() || "New agent").toLocaleLowerCase();
const rightTitle = (right.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, numeric: true,
sensitivity: "base", sensitivity: "base",
}); });
if (titleCmp !== 0) { if (titleDelta !== 0) {
return titleCmp; return titleDelta;
} }
return left.agent.id.localeCompare(right.agent.id, undefined, { return left.agent.id.localeCompare(right.agent.id, undefined, {
@@ -65,50 +64,66 @@ function compareByTitleAsc(
}); });
} }
function applySidebarDefaultOrdering( function toSidebarAgentKey(entry: SidebarAgentListEntry): string {
entries: SidebarAgentListEntry[] return `${entry.agent.serverId}:${entry.agent.id}`;
): { entries: SidebarAgentListEntry[]; hasMore: boolean } { }
const needsInput: SidebarAgentListEntry[] = [];
const failed: SidebarAgentListEntry[] = [];
const running: SidebarAgentListEntry[] = [];
const attention: SidebarAgentListEntry[] = [];
const done: SidebarAgentListEntry[] = [];
for (const entry of entries) { function applySidebarUserOrdering(input: {
const bucket = deriveSidebarStateBucket({ 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, status: entry.agent.status,
pendingPermissionCount: entry.agent.pendingPermissionCount, pendingPermissionCount: entry.agent.pendingPermissionCount,
requiresAttention: entry.agent.requiresAttention, requiresAttention: entry.agent.requiresAttention,
attentionReason: entry.agent.attentionReason, attentionReason: entry.agent.attentionReason,
}); });
if (bucket === "needs_input") { if (isActive) {
needsInput.push(entry); active.push(entry);
continue;
}
if (bucket === "failed") {
failed.push(entry);
continue;
}
if (bucket === "running") {
running.push(entry);
continue;
}
if (bucket === "attention") {
attention.push(entry);
continue; continue;
} }
done.push(entry); 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) { 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; const remainingDoneSlots = SIDEBAR_DONE_FILL_TARGET - active.length;
@@ -147,7 +162,7 @@ function toAggregatedAgent(params: {
export function useSidebarAgentsList(options?: { export function useSidebarAgentsList(options?: {
serverId?: string | null; serverId?: string | null;
selectedProjectFilterKeys?: string[]; selectedProjectFilterKey?: string | null;
}): SidebarAgentsListResult { }): SidebarAgentsListResult {
const { daemons } = useDaemonRegistry(); const { daemons } = useDaemonRegistry();
const runtime = getHostRuntimeStore(); const runtime = getHostRuntimeStore();
@@ -171,14 +186,16 @@ export function useSidebarAgentsList(options?: {
return daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId; return daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
}, [daemonLabelSignature, serverId]); }, [daemonLabelSignature, serverId]);
const selectedProjectFilterKeys = useMemo( const selectedProjectFilterKey = useMemo(() => {
() => const value = options?.selectedProjectFilterKey;
new Set( if (typeof value !== "string") {
(options?.selectedProjectFilterKeys ?? []) return null;
.map((item) => item.trim()) }
.filter((item) => item.length > 0) const trimmed = value.trim();
), return trimmed.length > 0 ? trimmed : null;
[options?.selectedProjectFilterKeys] }, [options?.selectedProjectFilterKey]);
const persistedOrder = useSidebarOrderStore((state) =>
serverId ? state.orderByServerId[serverId] ?? EMPTY_ORDER : EMPTY_ORDER
); );
const isActive = Boolean(serverId); const isActive = Boolean(serverId);
@@ -213,7 +230,12 @@ export function useSidebarAgentsList(options?: {
const [connectionStatus = "idle", directoryStatus = "idle"] = const [connectionStatus = "idle", directoryStatus = "idle"] =
runtimeStatusSignature.split(":", 2); runtimeStatusSignature.split(":", 2);
const { entries, projectFilterOptions, hasAnyData, hasMoreEntries } = useMemo(() => { const {
entries,
projectFilterOptions,
hasAnyData,
hasMoreEntries,
} = useMemo(() => {
if (!isActive || !serverId || !liveAgents) { if (!isActive || !serverId || !liveAgents) {
return { return {
entries: [] as SidebarAgentListEntry[], entries: [] as SidebarAgentListEntry[],
@@ -282,14 +304,15 @@ export function useSidebarAgentsList(options?: {
pushEntry({ agent, project }); pushEntry({ agent, project });
} }
const filteredEntries = const filteredEntries = selectedProjectFilterKey
selectedProjectFilterKeys.size > 0 ? mergedEntries.filter(
? mergedEntries.filter((entry) => (entry) => entry.project.projectKey === selectedProjectFilterKey
selectedProjectFilterKeys.has(entry.project.projectKey) )
) : mergedEntries;
: mergedEntries; const ordered = applySidebarUserOrdering({
entries: filteredEntries,
const ordered = applySidebarDefaultOrdering(filteredEntries); order: persistedOrder,
});
const options = Array.from(byProject.values()).sort((left, right) => { const options = Array.from(byProject.values()).sort((left, right) => {
if (left.activeCount !== right.activeCount) { if (left.activeCount !== right.activeCount) {
return right.activeCount - left.activeCount; return right.activeCount - left.activeCount;
@@ -297,14 +320,21 @@ export function useSidebarAgentsList(options?: {
return left.projectName.localeCompare(right.projectName); return left.projectName.localeCompare(right.projectName);
}); });
const result = { return {
entries: ordered.entries, entries: ordered.entries,
projectFilterOptions: options, projectFilterOptions: options,
hasAnyData: ordered.entries.length > 0, hasAnyData: mergedEntries.length > 0,
hasMoreEntries: ordered.hasMore, hasMoreEntries: ordered.hasMore,
}; };
return result; }, [
}, [agentLastActivity, isActive, liveAgents, selectedProjectFilterKeys, serverId, serverLabel]); agentLastActivity,
isActive,
liveAgents,
persistedOrder,
selectedProjectFilterKey,
serverId,
serverLabel,
]);
const refreshAll = useCallback(() => { const refreshAll = useCallback(() => {
if (!isActive || !serverId || connectionStatus !== "online") { 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,
}),
}
)
);