Improve sidebar agent list selection UI and drag behavior

This commit is contained in:
Mohamed Boudra
2026-03-01 15:44:58 +07:00
parent 2566171b4c
commit ec5b416b8a
7 changed files with 403 additions and 170 deletions
@@ -28,6 +28,7 @@ export function DraggableList<T>({
onRefresh, onRefresh,
simultaneousGestureRef, simultaneousGestureRef,
waitFor, waitFor,
onDragBegin: onDragBeginProp,
}: DraggableListProps<T>) { }: DraggableListProps<T>) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
@@ -61,7 +62,8 @@ export function DraggableList<T>({
const handleDragBegin = useCallback(() => { const handleDragBegin = useCallback(() => {
setIsDragging(true); setIsDragging(true);
}, []); onDragBeginProp?.();
}, [onDragBeginProp]);
const handleRelease = useCallback(() => { const handleRelease = useCallback(() => {
setIsDragging(false); setIsDragging(false);
@@ -30,4 +30,6 @@ export interface DraggableListProps<T> {
simultaneousGestureRef?: MutableRefObject<GestureType | undefined>; simultaneousGestureRef?: MutableRefObject<GestureType | undefined>;
/** Gesture ref(s) that the list should wait for before handling scroll */ /** Gesture ref(s) that the list should wait for before handling scroll */
waitFor?: MutableRefObject<GestureType | undefined> | MutableRefObject<GestureType | undefined>[]; waitFor?: MutableRefObject<GestureType | undefined> | MutableRefObject<GestureType | undefined>[];
/** Called when a drag gesture begins (before items are reordered) */
onDragBegin?: () => void;
} }
@@ -110,6 +110,7 @@ export function DraggableList<T>({
showsVerticalScrollIndicator = true, showsVerticalScrollIndicator = true,
enableDesktopWebScrollbar = false, enableDesktopWebScrollbar = false,
// simultaneousGestureRef is native-only, ignored on web // simultaneousGestureRef is native-only, ignored on web
onDragBegin,
}: DraggableListProps<T>) { }: DraggableListProps<T>) {
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [items, setItems] = useState(data); const [items, setItems] = useState(data);
@@ -134,7 +135,8 @@ export function DraggableList<T>({
const handleDragStart = useCallback((event: DragStartEvent) => { const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(String(event.active.id)); setActiveId(String(event.active.id));
}, []); onDragBegin?.();
}, [onDragBegin]);
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
(event: DragEndEvent) => { (event: DragEndEvent) => {
+27 -29
View File
@@ -398,21 +398,23 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
{projectFilterOptions.length > 0 ? ( {projectFilterOptions.length > 0 ? (
<Pressable <Pressable
ref={projectFilterAnchorRef} ref={projectFilterAnchorRef}
style={styles.filterButton} style={[
styles.filterButton,
selectedProjectFilterKey ? styles.filterButtonActive : null,
]}
onPress={() => setIsProjectFilterOpen(true)} onPress={() => setIsProjectFilterOpen(true)}
> >
{({ hovered }) => ( {({ hovered }) => (
<> <ListFilter
<ListFilter size={theme.iconSize.md}
size={theme.iconSize.md} color={
color={ selectedProjectFilterKey
(selectedProjectFilterKey || hovered) ? theme.colors.accentForeground
: hovered
? theme.colors.foreground ? theme.colors.foreground
: theme.colors.foregroundMuted : theme.colors.foregroundMuted
} }
/> />
{selectedProjectFilterKey ? <View style={styles.filterActiveDot} /> : null}
</>
)} )}
</Pressable> </Pressable>
) : null} ) : null}
@@ -553,21 +555,23 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
{projectFilterOptions.length > 0 ? ( {projectFilterOptions.length > 0 ? (
<Pressable <Pressable
ref={projectFilterAnchorRef} ref={projectFilterAnchorRef}
style={styles.filterButton} style={[
styles.filterButton,
selectedProjectFilterKey ? styles.filterButtonActive : null,
]}
onPress={() => setIsProjectFilterOpen(true)} onPress={() => setIsProjectFilterOpen(true)}
> >
{({ hovered }) => ( {({ hovered }) => (
<> <ListFilter
<ListFilter size={theme.iconSize.md}
size={theme.iconSize.md} color={
color={ selectedProjectFilterKey
(selectedProjectFilterKey || hovered) ? theme.colors.accentForeground
: hovered
? theme.colors.foreground ? theme.colors.foreground
: theme.colors.foregroundMuted : theme.colors.foregroundMuted
} }
/> />
{selectedProjectFilterKey ? <View style={styles.filterActiveDot} /> : null}
</>
)} )}
</Pressable> </Pressable>
) : null} ) : null}
@@ -727,16 +731,10 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center", justifyContent: "center",
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1], paddingHorizontal: theme.spacing[1],
position: "relative", borderRadius: theme.borderRadius.md,
}, },
filterActiveDot: { filterButtonActive: {
width: 8, backgroundColor: theme.colors.accent,
height: 8,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.borderAccent,
position: "absolute",
top: 0,
right: -2,
}, },
newAgentButtonHovered: {}, newAgentButtonHovered: {},
newAgentButtonText: { newAgentButtonText: {
@@ -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 } from "lucide-react-native"; import { Archive, Check, X } from "lucide-react-native";
import { import {
DraggableList, DraggableList,
type DraggableRenderItemInfo, type DraggableRenderItemInfo,
@@ -149,7 +149,6 @@ interface SidebarAgentRowProps {
onPress: () => void; onPress: () => void;
onLongPress: () => void; onLongPress: () => void;
onArchive: () => Promise<void>; onArchive: () => Promise<void>;
onToggleBatch: () => void;
} }
function resolveBranchLabel(entry: SidebarAgentListEntry): string | null { function resolveBranchLabel(entry: SidebarAgentListEntry): string | null {
@@ -176,7 +175,6 @@ function SidebarAgentRow({
onPress, onPress,
onLongPress, onLongPress,
onArchive, onArchive,
onToggleBatch,
}: SidebarAgentRowProps) { }: SidebarAgentRowProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
@@ -240,6 +238,7 @@ function SidebarAgentRow({
]} ]}
onPress={onPress} onPress={onPress}
onLongPress={onLongPress} onLongPress={onLongPress}
delayLongPress={200}
onPressIn={() => { onPressIn={() => {
if (Platform.OS !== "web") { if (Platform.OS !== "web") {
return; return;
@@ -251,27 +250,10 @@ function SidebarAgentRow({
testID={`agent-row-${entry.agent.serverId}-${entry.agent.id}`} testID={`agent-row-${entry.agent.serverId}-${entry.agent.id}`}
> >
<View style={styles.agentRowTop}> <View style={styles.agentRowTop}>
{isInSelectionMode ? ( <AgentStatusDot
<Pressable status={entry.agent.status}
onPress={(event) => { requiresAttention={entry.agent.requiresAttention}
event.stopPropagation(); />
onToggleBatch();
}}
style={[
styles.checkbox,
isBatchSelected && styles.checkboxSelected,
]}
>
{isBatchSelected ? (
<Check size={theme.iconSize.xs} color={theme.colors.primaryForeground} />
) : null}
</Pressable>
) : (
<AgentStatusDot
status={entry.agent.status}
requiresAttention={entry.agent.requiresAttention}
/>
)}
<Text <Text
style={[ style={[
@@ -379,6 +361,16 @@ function SidebarAgentRow({
</View> </View>
) : null} ) : null}
</View> </View>
{isBatchSelected ? (
<View style={styles.selectionOverlay} pointerEvents="none">
<Check
size={theme.iconSize.md}
color={theme.colors.accentForeground}
strokeWidth={2.5}
/>
</View>
) : null}
</Pressable> </Pressable>
); );
} }
@@ -607,9 +599,21 @@ export function SidebarAgentList({
const handleAgentLongPress = useCallback((entry: SidebarAgentListEntry) => { const handleAgentLongPress = useCallback((entry: SidebarAgentListEntry) => {
const key = `${entry.agent.serverId}:${entry.agent.id}`; const key = `${entry.agent.serverId}:${entry.agent.id}`;
if (isSelectionMode) {
setSelectedBatchKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
return;
}
setIsSelectionMode(true); setIsSelectionMode(true);
setSelectedBatchKeys(new Set([key])); setSelectedBatchKeys(new Set([key]));
}, []); }, [isSelectionMode]);
const handleArchiveSingle = useCallback( const handleArchiveSingle = useCallback(
async (entry: SidebarAgentListEntry): Promise<void> => { async (entry: SidebarAgentListEntry): Promise<void> => {
@@ -667,8 +671,14 @@ export function SidebarAgentList({
setIsSelectionMode(false); setIsSelectionMode(false);
}, []); }, []);
const handleListDragBegin = useCallback(() => {
// No-op: selection mode is resolved in handleDragEnd based on whether
// the item actually moved. This fires synchronously when drag() is called
// (i.e., on long press), before we know if the user will drag or release.
}, []);
const renderRow = useCallback( const renderRow = useCallback(
({ item }: DraggableRenderItemInfo<EntryData>) => { ({ item, drag }: DraggableRenderItemInfo<EntryData>) => {
const key = `${item.agent.serverId}:${item.agent.id}`; const key = `${item.agent.serverId}:${item.agent.id}`;
const projectDisplayName = deriveProjectDisplayName({ const projectDisplayName = deriveProjectDisplayName({
projectKey: item.project.projectKey, projectKey: item.project.projectKey,
@@ -693,19 +703,11 @@ export function SidebarAgentList({
showShortcutBadges ? (shortcutIndexByAgentKey.get(key) ?? null) : null showShortcutBadges ? (shortcutIndexByAgentKey.get(key) ?? null) : null
} }
onPress={() => handleAgentPress(item)} onPress={() => handleAgentPress(item)}
onLongPress={() => handleAgentLongPress(item)} onLongPress={() => {
onArchive={() => handleArchiveSingle(item)} drag();
onToggleBatch={() => { handleAgentLongPress(item);
setSelectedBatchKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}} }}
onArchive={() => handleArchiveSingle(item)}
/> />
); );
}, },
@@ -735,8 +737,18 @@ export function SidebarAgentList({
const reorderedKeys = reorderedEntries.map( const reorderedKeys = reorderedEntries.map(
(entry) => `${entry.agent.serverId}:${entry.agent.id}` (entry) => `${entry.agent.serverId}:${entry.agent.id}`
); );
const currentOrder = getSidebarOrder(serverId);
const currentVisible = currentOrder.filter((key) =>
reorderedKeys.includes(key)
);
const orderChanged =
currentVisible.length !== reorderedKeys.length ||
reorderedKeys.some((key, i) => currentVisible[i] !== key);
if (!orderChanged) {
return;
}
const reorderedSet = new Set(reorderedKeys); const reorderedSet = new Set(reorderedKeys);
const remainder = getSidebarOrder(serverId).filter((key) => !reorderedSet.has(key)); const remainder = currentOrder.filter((key) => !reorderedSet.has(key));
setSidebarOrder(serverId, [...reorderedKeys, ...remainder]); setSidebarOrder(serverId, [...reorderedKeys, ...remainder]);
}, },
[getSidebarOrder, serverId, setSidebarOrder] [getSidebarOrder, serverId, setSidebarOrder]
@@ -808,24 +820,39 @@ export function SidebarAgentList({
refreshing={isRefreshing} refreshing={isRefreshing}
onRefresh={onRefresh} onRefresh={onRefresh}
simultaneousGestureRef={parentGestureRef} simultaneousGestureRef={parentGestureRef}
onDragBegin={handleListDragBegin}
/> />
{isSelectionMode ? ( {isSelectionMode ? (
<View style={styles.selectionBar}> <View style={styles.selectionBar}>
<Pressable style={styles.selectionAction} onPress={handleSelectionBack}> <Text style={styles.selectionBarCountText}>
<Text style={styles.selectionActionText}>Back</Text> {selectedBatchKeys.size} selected
</Pressable> </Text>
<Pressable <View style={styles.selectionBarActions}>
style={[ <Pressable
styles.selectionAction, style={[
styles.selectionArchiveAction, styles.selectionBarButton,
selectedBatchKeys.size === 0 && styles.selectionArchiveActionDisabled, selectedBatchKeys.size === 0 && styles.selectionBarButtonDisabled,
]} ]}
disabled={selectedBatchKeys.size === 0} disabled={selectedBatchKeys.size === 0}
onPress={handleArchiveBatch} onPress={handleArchiveBatch}
> >
<Text style={styles.selectionArchiveActionText}>Archive</Text> <Archive
</Pressable> size={theme.iconSize.md}
color={theme.colors.accentForeground}
/>
</Pressable>
<Pressable
style={styles.selectionBarButton}
onPress={handleSelectionBack}
>
<X
size={theme.iconSize.md}
color={theme.colors.accentForeground}
strokeWidth={2.5}
/>
</Pressable>
</View>
</View> </View>
) : null} ) : null}
</View> </View>
@@ -910,6 +937,7 @@ const styles = StyleSheet.create((theme) => ({
}, },
listContent: { listContent: {
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4], paddingBottom: theme.spacing[4],
}, },
listContentSelectionMode: { listContentSelectionMode: {
@@ -936,19 +964,19 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2], gap: theme.spacing[2],
minHeight: 20, minHeight: 20,
}, },
checkbox: { selectionOverlay: {
width: theme.iconSize.md, position: "absolute",
height: theme.iconSize.md, top: 0,
borderRadius: theme.borderRadius.sm, left: 0,
borderWidth: 1, right: 0,
borderColor: theme.colors.border, bottom: 0,
borderRadius: theme.borderRadius.lg,
borderWidth: 2,
borderColor: theme.colors.accent,
backgroundColor: `${theme.colors.accent}1F`,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
checkboxSelected: {
backgroundColor: theme.colors.primary,
borderColor: theme.colors.primary,
},
agentTitle: { agentTitle: {
flex: 1, flex: 1,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
@@ -1035,37 +1063,33 @@ const styles = StyleSheet.create((theme) => ({
left: theme.spacing[2], left: theme.spacing[2],
right: theme.spacing[2], right: theme.spacing[2],
bottom: theme.spacing[2], bottom: theme.spacing[2],
borderRadius: theme.borderRadius.xl, borderRadius: theme.borderRadius["2xl"],
borderWidth: 1, backgroundColor: theme.colors.accent,
borderColor: theme.colors.border, paddingVertical: theme.spacing[3],
backgroundColor: theme.colors.surface2, paddingHorizontal: theme.spacing[4],
padding: theme.spacing[2],
flexDirection: "row", flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
selectionBarCountText: {
color: theme.colors.accentForeground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
selectionBarActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
selectionAction: { selectionBarButton: {
flex: 1, width: 36,
borderRadius: theme.borderRadius.lg, height: 36,
borderWidth: 1, borderRadius: theme.borderRadius.full,
borderColor: theme.colors.border, backgroundColor: "rgba(0, 0, 0, 0.15)",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.surface1,
}, },
selectionActionText: { selectionBarButtonDisabled: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
selectionArchiveAction: {
backgroundColor: theme.colors.primary,
borderColor: theme.colors.primary,
},
selectionArchiveActionDisabled: {
opacity: 0.5, opacity: 0.5,
}, },
selectionArchiveActionText: {
color: theme.colors.primaryForeground,
fontSize: theme.fontSize.sm,
},
})); }));
@@ -0,0 +1,189 @@
import { describe, expect, it } from "vitest";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import {
applySidebarUserOrdering,
type SidebarAgentListEntry,
} from "./use-sidebar-agents-list";
function toKey(entry: SidebarAgentListEntry): string {
return `${entry.agent.serverId}:${entry.agent.id}`;
}
function createEntry(input: {
id: string;
createdAt: string;
status?: AgentLifecycleStatus;
serverId?: string;
}): SidebarAgentListEntry {
const serverId = input.serverId ?? "server";
const createdAt = new Date(input.createdAt);
return {
agent: {
id: input.id,
serverId,
serverLabel: serverId,
title: input.id,
status: input.status ?? "idle",
createdAt,
lastActivityAt: createdAt,
cwd: "/tmp/project",
provider: "codex",
pendingPermissionCount: 0,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
labels: { ui: "true" },
},
project: {
projectKey: "project",
projectName: "Project",
checkout: {
cwd: "/tmp/project",
isGit: false,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
};
}
describe("applySidebarUserOrdering", () => {
it("keeps unknown entries on the chronological baseline", () => {
const entries = [
createEntry({ id: "a", createdAt: "2026-03-01T10:00:00.000Z" }),
createEntry({ id: "b", createdAt: "2026-02-28T10:00:00.000Z" }),
createEntry({ id: "x", createdAt: "2026-02-01T10:00:00.000Z" }),
];
const result = applySidebarUserOrdering({
entries,
order: ["server:b", "server:a"],
});
expect(result.entries.map(toKey)).toEqual(["server:b", "server:a", "server:x"]);
});
it("places new entries first while preserving persisted order for known entries", () => {
const entries = [
createEntry({ id: "new", createdAt: "2026-03-02T10:00:00.000Z" }),
createEntry({ id: "a", createdAt: "2026-03-01T10:00:00.000Z" }),
createEntry({ id: "b", createdAt: "2026-02-28T10:00:00.000Z" }),
];
const result = applySidebarUserOrdering({
entries,
order: ["server:b", "server:a"],
});
expect(result.entries.map(toKey)).toEqual([
"server:new",
"server:b",
"server:a",
]);
});
it("keeps persisted placement stable across status changes", () => {
const baseEntries = [
createEntry({
id: "a",
createdAt: "2026-03-01T10:00:00.000Z",
status: "running",
}),
createEntry({
id: "b",
createdAt: "2026-02-28T10:00:00.000Z",
status: "idle",
}),
createEntry({
id: "c",
createdAt: "2026-02-27T10:00:00.000Z",
status: "idle",
}),
];
const order = ["server:c", "server:a", "server:b"];
const initial = applySidebarUserOrdering({ entries: baseEntries, order });
expect(initial.entries.map(toKey)).toEqual(order);
const afterStatusChange = applySidebarUserOrdering({
entries: baseEntries.map((entry) =>
entry.agent.id === "a"
? {
...entry,
agent: {
...entry.agent,
status: "idle",
},
}
: entry
),
order,
});
expect(afterStatusChange.entries.map(toKey)).toEqual(order);
});
it("always includes active entries while capping done entries to 50", () => {
const doneEntries = Array.from({ length: 55 }, (_, index) =>
createEntry({
id: `done-${index + 1}`,
createdAt: new Date(
Date.UTC(2026, 2, 1, 12, 0, 0) - index * 60_000
).toISOString(),
status: "idle",
})
);
const activeEntry = createEntry({
id: "active-oldest",
createdAt: "2026-01-01T10:00:00.000Z",
status: "running",
});
const result = applySidebarUserOrdering({
entries: [...doneEntries, activeEntry],
order: [],
});
expect(result.entries).toHaveLength(51);
expect(result.entries.some((entry) => entry.agent.id === "active-oldest")).toBe(
true
);
expect(result.entries.filter((entry) => entry.agent.status === "idle")).toHaveLength(
50
);
expect(result.hasMore).toBe(true);
});
it("prioritizes newest done entries for the 50-item done window", () => {
const oldDone = Array.from({ length: 55 }, (_, index) =>
createEntry({
id: `old-${index + 1}`,
createdAt: new Date(
Date.UTC(2026, 1, 1, 12, 0, 0) - index * 60_000
).toISOString(),
status: "idle",
})
);
const recentDone = [
createEntry({ id: "recent-1", createdAt: "2026-03-01T12:00:00.000Z" }),
createEntry({ id: "recent-2", createdAt: "2026-03-01T11:59:00.000Z" }),
createEntry({ id: "recent-3", createdAt: "2026-03-01T11:58:00.000Z" }),
];
const entries = [...oldDone, ...recentDone];
const pollutedOrder = [...oldDone, ...recentDone].map(toKey);
const result = applySidebarUserOrdering({
entries,
order: pollutedOrder,
});
const visibleKeys = result.entries.map(toKey);
expect(result.entries).toHaveLength(50);
expect(visibleKeys).toContain("server:recent-1");
expect(visibleKeys).toContain("server:recent-2");
expect(visibleKeys).toContain("server:recent-3");
expect(visibleKeys).not.toContain("server:old-55");
expect(result.hasMore).toBe(true);
});
});
@@ -68,69 +68,85 @@ function toSidebarAgentKey(entry: SidebarAgentListEntry): string {
return `${entry.agent.serverId}:${entry.agent.id}`; return `${entry.agent.serverId}:${entry.agent.id}`;
} }
function applySidebarUserOrdering(input: { export function applySidebarUserOrdering(input: {
entries: SidebarAgentListEntry[]; entries: SidebarAgentListEntry[];
order: string[]; order: string[];
}): { entries: SidebarAgentListEntry[]; hasMore: boolean } { }): { entries: SidebarAgentListEntry[]; hasMore: boolean } {
const entryKeySet = new Set(input.entries.map((entry) => toSidebarAgentKey(entry))); const sortedByCreatedAt = [...input.entries].sort(compareByCreatedAtDesc);
const prunedOrder = input.order.filter((key) => entryKeySet.has(key)); const entryByKey = new Map<string, SidebarAgentListEntry>();
const knownOrderSet = new Set(prunedOrder); for (const entry of sortedByCreatedAt) {
const newEntries = input.entries entryByKey.set(toSidebarAgentKey(entry), entry);
.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 prunedOrder: string[] = [];
const leftOrder = orderIndexByKey.get(toSidebarAgentKey(left)); const seenOrderKeys = new Set<string>();
const rightOrder = orderIndexByKey.get(toSidebarAgentKey(right)); for (const key of input.order) {
if (!entryByKey.has(key) || seenOrderKeys.has(key)) {
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 (isActive) {
active.push(entry);
continue; continue;
} }
done.push(entry); seenOrderKeys.add(key);
prunedOrder.push(key);
} }
if (active.length >= SIDEBAR_DONE_FILL_TARGET) { const sorted: SidebarAgentListEntry[] = [];
return { if (prunedOrder.length === 0) {
entries: active, sorted.push(...sortedByCreatedAt);
hasMore: done.length > 0, } else {
}; const knownOrderSet = new Set(prunedOrder);
let knownOrderIndex = 0;
for (const entry of sortedByCreatedAt) {
const key = toSidebarAgentKey(entry);
if (!knownOrderSet.has(key)) {
sorted.push(entry);
continue;
}
const orderedKey = prunedOrder[knownOrderIndex] ?? key;
knownOrderIndex += 1;
sorted.push(entryByKey.get(orderedKey) ?? entry);
}
}
const doneEntries = sorted.filter(
(entry) =>
!isSidebarActiveAgent({
status: entry.agent.status,
pendingPermissionCount: entry.agent.pendingPermissionCount,
requiresAttention: entry.agent.requiresAttention,
attentionReason: entry.agent.attentionReason,
})
);
const visibleDoneKeys = new Set(
[...doneEntries]
.sort(compareByCreatedAtDesc)
.slice(0, SIDEBAR_DONE_FILL_TARGET)
.map((entry) => toSidebarAgentKey(entry))
);
const visible: SidebarAgentListEntry[] = [];
let hiddenDoneCount = 0;
for (const entry of sorted) {
const key = toSidebarAgentKey(entry);
if (
isSidebarActiveAgent({
status: entry.agent.status,
pendingPermissionCount: entry.agent.pendingPermissionCount,
requiresAttention: entry.agent.requiresAttention,
attentionReason: entry.agent.attentionReason,
})
) {
visible.push(entry);
continue;
}
if (visibleDoneKeys.has(key)) {
visible.push(entry);
continue;
}
hiddenDoneCount += 1;
} }
const remainingDoneSlots = SIDEBAR_DONE_FILL_TARGET - active.length;
const shownDone = done.slice(0, remainingDoneSlots);
return { return {
entries: [...active, ...shownDone], entries: visible,
hasMore: done.length > shownDone.length, hasMore: hiddenDoneCount > 0,
}; };
} }