Flatten home screen agent list and sort by activity
The agent list on the home screen now displays all agents in a single flat list rather than grouped by host. Each row shows a host badge next to the agent title so users can still identify which host an agent belongs to. Agents are sorted globally by status (running first) then by most recent activity, providing a unified view across all hosts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ import { useLocalSearchParams } from "expo-router";
|
||||
export default function HomeScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { theme } = useUnistyles();
|
||||
const { groups: aggregatedAgents, isLoading } = useAggregatedAgents();
|
||||
const { agents: aggregatedAgents, isLoading } = useAggregatedAgents();
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [createModalMounted, setCreateModalMounted] = useState(false);
|
||||
@@ -39,8 +39,7 @@ export default function HomeScreen() {
|
||||
};
|
||||
});
|
||||
|
||||
const aggregatedCount = aggregatedAgents.reduce((count, group) => count + group.agents.length, 0);
|
||||
const hasAgents = aggregatedCount > 0;
|
||||
const hasAgents = aggregatedAgents.length > 0;
|
||||
|
||||
const handleCreateAgent = useCallback(() => {
|
||||
setCreateModalMounted(true);
|
||||
@@ -112,7 +111,7 @@ export default function HomeScreen() {
|
||||
<ActivityIndicator size="large" color={theme.colors.mutedForeground} />
|
||||
</View>
|
||||
) : hasAgents ? (
|
||||
<AgentList agentGroups={aggregatedAgents} />
|
||||
<AgentList agents={aggregatedAgents} />
|
||||
) : (
|
||||
<EmptyState
|
||||
onCreateAgent={handleCreateAgent}
|
||||
|
||||
@@ -2,29 +2,27 @@ import { View, Text, Pressable, ScrollView, Modal } from "react-native";
|
||||
import { useCallback, useState } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status";
|
||||
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { type AggregatedAgentGroup } from "@/hooks/use-aggregated-agents";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
||||
|
||||
interface AgentListProps {
|
||||
agentGroups: AggregatedAgentGroup[];
|
||||
agents: AggregatedAgent[];
|
||||
}
|
||||
|
||||
export function AgentList({ agentGroups }: AgentListProps) {
|
||||
export function AgentList({ agents }: AgentListProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [actionAgent, setActionAgent] = useState<Agent | null>(null);
|
||||
const [actionAgentServerId, setActionAgentServerId] = useState<string | null>(null);
|
||||
const actionSession = useDaemonSession(actionAgentServerId ?? undefined, {
|
||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||
const actionSession = useDaemonSession(actionAgent?.serverId, {
|
||||
suppressUnavailableAlert: true,
|
||||
allowUnavailable: true,
|
||||
});
|
||||
|
||||
const deleteAgent = actionSession?.deleteAgent;
|
||||
const isActionSheetVisible = actionAgent !== null;
|
||||
const isActionDaemonUnavailable = Boolean(actionAgentServerId && !actionSession);
|
||||
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionSession);
|
||||
|
||||
const handleAgentPress = useCallback(
|
||||
(serverId: string, agentId: string) => {
|
||||
@@ -42,14 +40,12 @@ export function AgentList({ agentGroups }: AgentListProps) {
|
||||
[isActionSheetVisible]
|
||||
);
|
||||
|
||||
const handleAgentLongPress = useCallback((serverId: string, agent: Agent) => {
|
||||
setActionAgentServerId(serverId);
|
||||
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
|
||||
setActionAgent(agent);
|
||||
}, []);
|
||||
|
||||
const handleCloseActionSheet = useCallback(() => {
|
||||
setActionAgent(null);
|
||||
setActionAgentServerId(null);
|
||||
}, []);
|
||||
|
||||
const handleDeleteAgent = useCallback(() => {
|
||||
@@ -58,77 +54,80 @@ export function AgentList({ agentGroups }: AgentListProps) {
|
||||
}
|
||||
deleteAgent(actionAgent.id);
|
||||
setActionAgent(null);
|
||||
setActionAgentServerId(null);
|
||||
}, [actionAgent, deleteAgent]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
|
||||
{agentGroups.map(({ serverId, serverLabel, agents }) => (
|
||||
<View key={serverId} style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>{serverLabel}</Text>
|
||||
{agents.map((agent) => {
|
||||
const statusColor = getAgentStatusColor(agent.status);
|
||||
const statusLabel = getAgentStatusLabel(agent.status);
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||
const providerLabel = getAgentProviderDefinition(agent.provider).label;
|
||||
const rowServerId = agent.serverId ?? serverId;
|
||||
{agents.map((agent) => {
|
||||
const statusColor = getAgentStatusColor(agent.status);
|
||||
const statusLabel = getAgentStatusLabel(agent.status);
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||
const providerLabel = getAgentProviderDefinition(agent.provider).label;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={`${rowServerId}:${agent.id}`}
|
||||
style={({ pressed }) => [
|
||||
styles.agentItem,
|
||||
pressed && styles.agentItemPressed,
|
||||
]}
|
||||
onPress={() => handleAgentPress(rowServerId, agent.id)}
|
||||
onLongPress={() => handleAgentLongPress(rowServerId, agent)}
|
||||
>
|
||||
<View style={styles.agentContent}>
|
||||
return (
|
||||
<Pressable
|
||||
key={`${agent.serverId}:${agent.id}`}
|
||||
style={({ pressed }) => [
|
||||
styles.agentItem,
|
||||
pressed && styles.agentItemPressed,
|
||||
]}
|
||||
onPress={() => handleAgentPress(agent.serverId, agent.id)}
|
||||
onLongPress={() => handleAgentLongPress(agent)}
|
||||
>
|
||||
<View style={styles.agentContent}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text
|
||||
style={styles.agentTitle}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{agent.title || "New Agent"}
|
||||
</Text>
|
||||
<View style={[styles.hostBadge, { backgroundColor: theme.colors.muted }]}>
|
||||
<Text
|
||||
style={styles.agentTitle}
|
||||
style={[styles.hostText, { color: theme.colors.mutedForeground }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{agent.title || "New Agent"}
|
||||
{agent.serverLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.agentDirectory} numberOfLines={1}>
|
||||
{agent.cwd}
|
||||
</Text>
|
||||
<Text style={styles.agentDirectory} numberOfLines={1}>
|
||||
{agent.cwd}
|
||||
</Text>
|
||||
|
||||
<View style={styles.statusRow}>
|
||||
<View style={styles.statusGroup}>
|
||||
<View
|
||||
style={[styles.providerBadge, { backgroundColor: theme.colors.muted }]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.providerText, { color: theme.colors.mutedForeground }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{providerLabel}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.statusRow}>
|
||||
<View style={styles.statusGroup}>
|
||||
<View
|
||||
style={[styles.providerBadge, { backgroundColor: theme.colors.muted }]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.providerText, { color: theme.colors.mutedForeground }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{providerLabel}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statusBadge}>
|
||||
<View
|
||||
style={[styles.statusDot, { backgroundColor: statusColor }]}
|
||||
/>
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>
|
||||
{statusLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.timeAgo}>
|
||||
{timeAgo}
|
||||
<View style={styles.statusBadge}>
|
||||
<View
|
||||
style={[styles.statusDot, { backgroundColor: statusColor }]}
|
||||
/>
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>
|
||||
{statusLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
|
||||
<Text style={styles.timeAgo}>
|
||||
{timeAgo}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
<Modal
|
||||
@@ -182,17 +181,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingTop: theme.spacing[4],
|
||||
},
|
||||
section: {
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.mutedForeground,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
agentItem: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
@@ -207,11 +195,27 @@ const styles = StyleSheet.create((theme) => ({
|
||||
agentContent: {
|
||||
flex: 1,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
agentTitle: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
hostBadge: {
|
||||
borderRadius: theme.borderRadius.full,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 2,
|
||||
},
|
||||
hostText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
agentDirectory: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -3,82 +3,50 @@ import type { Agent } from "@/contexts/session-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useSessionDirectory } from "@/hooks/use-session-directory";
|
||||
|
||||
export interface AggregatedAgentGroup {
|
||||
export interface AggregatedAgent extends Agent {
|
||||
serverId: string;
|
||||
serverLabel: string;
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
export interface AggregatedAgentsResult {
|
||||
groups: AggregatedAgentGroup[];
|
||||
agents: AggregatedAgent[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const sortAgents = (agents: Agent[]): Agent[] => {
|
||||
return [...agents].sort((left, right) => {
|
||||
const leftRunning = left.status === "running";
|
||||
const rightRunning = right.status === "running";
|
||||
if (leftRunning && !rightRunning) {
|
||||
return -1;
|
||||
}
|
||||
if (!leftRunning && rightRunning) {
|
||||
return 1;
|
||||
}
|
||||
const leftTime = (left.lastUserMessageAt ?? left.lastActivityAt).getTime();
|
||||
const rightTime = (right.lastUserMessageAt ?? right.lastActivityAt).getTime();
|
||||
return rightTime - leftTime;
|
||||
});
|
||||
};
|
||||
|
||||
export function useAggregatedAgents(): AggregatedAgentsResult {
|
||||
const { connectionStates, isLoading: registryLoading } = useDaemonConnections();
|
||||
const sessionDirectory = useSessionDirectory();
|
||||
|
||||
return useMemo(() => {
|
||||
const groups = new Map<string, { serverLabel: string; agents: Agent[] }>();
|
||||
const daemonOrder = new Map<string, number>();
|
||||
Array.from(connectionStates.keys()).forEach((serverId, index) => {
|
||||
daemonOrder.set(serverId, index);
|
||||
const allAgents: AggregatedAgent[] = [];
|
||||
|
||||
sessionDirectory.forEach((session, serverId) => {
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
for (const agent of session.agents.values()) {
|
||||
allAgents.push({
|
||||
...agent,
|
||||
serverId,
|
||||
serverLabel,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (sessionDirectory.size > 0) {
|
||||
sessionDirectory.forEach((session, serverId) => {
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
const label = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
const sessionAgents = Array.from(session.agents.values());
|
||||
if (sessionAgents.length === 0) {
|
||||
return;
|
||||
}
|
||||
const existing = groups.get(serverId);
|
||||
if (existing) {
|
||||
existing.agents.push(...sessionAgents);
|
||||
} else {
|
||||
groups.set(serverId, { serverLabel: label, agents: sessionAgents });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const aggregatedGroups = Array.from(groups.entries()).map(([serverId, { serverLabel, agents }]) => ({
|
||||
serverId,
|
||||
serverLabel,
|
||||
agents: sortAgents(agents),
|
||||
}));
|
||||
|
||||
aggregatedGroups.sort((left, right) => {
|
||||
const leftIndex = daemonOrder.get(left.serverId);
|
||||
const rightIndex = daemonOrder.get(right.serverId);
|
||||
if (leftIndex !== undefined && rightIndex !== undefined && leftIndex !== rightIndex) {
|
||||
return leftIndex - rightIndex;
|
||||
}
|
||||
if (leftIndex !== undefined) {
|
||||
// Sort by: running agents first, then by most recent activity
|
||||
allAgents.sort((left, right) => {
|
||||
const leftRunning = left.status === "running";
|
||||
const rightRunning = right.status === "running";
|
||||
if (leftRunning && !rightRunning) {
|
||||
return -1;
|
||||
}
|
||||
if (rightIndex !== undefined) {
|
||||
if (!leftRunning && rightRunning) {
|
||||
return 1;
|
||||
}
|
||||
return left.serverLabel.localeCompare(right.serverLabel);
|
||||
const leftTime = (left.lastUserMessageAt ?? left.lastActivityAt).getTime();
|
||||
const rightTime = (right.lastUserMessageAt ?? right.lastActivityAt).getTime();
|
||||
return rightTime - leftTime;
|
||||
});
|
||||
|
||||
// Loading if registry is still loading, or if any host is connecting and hasn't
|
||||
@@ -94,6 +62,6 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
|
||||
}
|
||||
}
|
||||
|
||||
return { groups: aggregatedGroups, isLoading };
|
||||
return { agents: allAgents, isLoading };
|
||||
}, [sessionDirectory, connectionStates, registryLoading]);
|
||||
}
|
||||
|
||||
@@ -104,15 +104,18 @@ The multi-daemon infrastructure is in place: session directory with daemon-scope
|
||||
- Context (review): `HomeScreen` still flips directly to the "New/Import Agent" empty state whenever `aggregatedAgents` is empty, so offline hosts with agents appear as if there are zero agents and there's no neutral loading indicator (`packages/app/src/app/index.tsx:109-116`).
|
||||
- Review (2025-11-26): This task is still open. The home screen shows an empty state immediately if `aggregatedCount === 0`, with no distinction between "loading" and "truly empty." Offline hosts that may have agents will show zero agents until they reconnect.
|
||||
- Completed: Updated `useAggregatedAgents` to return `{ groups, isLoading }` where `isLoading` is true while the daemon registry loads or while hosts are `connecting` without a session yet. Updated `HomeScreen` to show an `ActivityIndicator` during loading, then either the agent list or empty state. Offline hosts don't block loading—only `connecting` hosts do.
|
||||
- [ ] Remove grouping of agents by host—show a single flat list.
|
||||
- [x] Remove grouping of agents by host—show a single flat list.
|
||||
- Context (review): `packages/app/src/components/agent-list.tsx:67-131` still maps `agentGroups` into host-specific sections with headers, so grouping hasn't been removed.
|
||||
- Review (2025-11-26): Still open. The `AgentList` component iterates over `agentGroups` and renders a section header (`sectionLabel`) per host.
|
||||
- [ ] Each agent row displays its host name as metadata (badge, subtitle, etc.).
|
||||
- Completed: Refactored `useAggregatedAgents` to return a flat `AggregatedAgent[]` array (each agent carries `serverId` and `serverLabel`). Updated `AgentList` to render a flat list with a host badge in each row's title area. Removed section headers and grouping logic entirely.
|
||||
- [x] Each agent row displays its host name as metadata (badge, subtitle, etc.).
|
||||
- Context (review): Agent rows only render cwd/provider/status/time (`packages/app/src/components/agent-list.tsx:87-125`), so there's no host metadata visible per row yet.
|
||||
- Review (2025-11-26): Still open. To flatten the list, host metadata must move into each row since section headers will be removed.
|
||||
- [ ] Sort agents by recent activity or alphabetically (not by host).
|
||||
- Completed: Added `hostBadge` to each agent row showing `serverLabel` in a muted badge next to the agent title.
|
||||
- [x] Sort agents by recent activity or alphabetically (not by host).
|
||||
- Context (review): `packages/app/src/hooks/use-aggregated-agents.ts:25-66` sorts sections by host registration order and only orders agents within a host, so we still bias the list ordering by host rather than recency across all agents.
|
||||
- Review (2025-11-26): Still open. The current `useAggregatedAgents` hook returns grouped data; it needs refactoring to return a flat, globally-sorted array.
|
||||
- Completed: Agents are now sorted globally by status (running first) then by most recent activity (`lastUserMessageAt` or `lastActivityAt`), not by host.
|
||||
|
||||
### 9. Review: Git Diff Metadata Cleanup
|
||||
- [x] Remove the now-unused `routeServerId` prop from `GitDiffContent` (`packages/app/src/app/git-diff.tsx:84-104`) so we aren't plumbing dead state through the component after switching to `serverLabel`.
|
||||
@@ -120,33 +123,16 @@ The multi-daemon infrastructure is in place: session directory with daemon-scope
|
||||
|
||||
---
|
||||
|
||||
## Review Summary (2025-11-26)
|
||||
## Review Summary (2025-11-26, updated)
|
||||
|
||||
**Completed:**
|
||||
- Tasks 1–7 are complete
|
||||
- Task 9 loading indicator is complete
|
||||
- Tasks 1–9 are complete
|
||||
- Internal code correctly uses "daemon" terminology while UI says "host"
|
||||
- Connection banners removed from home screen
|
||||
- Error philosophy fixed for Git Diff, File Explorer, and agent action sheets
|
||||
- Settings screen retains host status indicators (correct per guiding principles)
|
||||
- Create agent modal availability check fixed (uses `connectionStates` as single source of truth)
|
||||
- Home screen agent list is now a flat list sorted by activity with host badges per row
|
||||
- Typecheck passes
|
||||
|
||||
**Remaining Work:**
|
||||
|
||||
### Task 8: Create Agent Modal Availability Check
|
||||
The modal incorrectly shows hosts as offline when they're actually online. Root cause is a timing issue where `session` is null during the brief window between `connectionStates` updating and `SessionProvider` registering its session accessor.
|
||||
|
||||
**Fix required in `packages/app/src/components/create-agent-modal.tsx`:**
|
||||
- Line 333-334: Change `selectedDaemonIsUnavailable` to only check `selectedDaemonStatus !== "online"`
|
||||
- Don't require `session` to exist for the availability check—`session` is only needed for actual operations
|
||||
- The availability UX should use `connectionStates` as single source of truth
|
||||
|
||||
### Task 9: Home Screen Agent List (partial)
|
||||
The loading indicator is complete. Still need:
|
||||
1. **Flat list**: `AgentList` still renders sections with host headers via `agentGroups.map()`
|
||||
2. **Host metadata per row**: Agent rows don't show which host they belong to (required once sections are removed)
|
||||
3. **Global sorting**: Agents are still grouped by host order; need to flatten and sort by recency across all hosts
|
||||
|
||||
**Files to modify for Task 9:**
|
||||
- `packages/app/src/hooks/use-aggregated-agents.ts` — return flat array sorted by activity
|
||||
- `packages/app/src/components/agent-list.tsx` — remove section headers, add host badge to rows
|
||||
**All tasks complete.**
|
||||
|
||||
Reference in New Issue
Block a user