Simplify project filter to single selection and persist sidebar order
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user