diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 2e36297b7..73bcf6639 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -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() { ) : hasAgents ? ( - + ) : ( (null); - const [actionAgentServerId, setActionAgentServerId] = useState(null); - const actionSession = useDaemonSession(actionAgentServerId ?? undefined, { + const [actionAgent, setActionAgent] = useState(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 ( <> - {agentGroups.map(({ serverId, serverLabel, agents }) => ( - - {serverLabel} - {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 ( - [ - styles.agentItem, - pressed && styles.agentItemPressed, - ]} - onPress={() => handleAgentPress(rowServerId, agent.id)} - onLongPress={() => handleAgentLongPress(rowServerId, agent)} - > - + return ( + [ + styles.agentItem, + pressed && styles.agentItemPressed, + ]} + onPress={() => handleAgentPress(agent.serverId, agent.id)} + onLongPress={() => handleAgentLongPress(agent)} + > + + + + {agent.title || "New Agent"} + + - {agent.title || "New Agent"} + {agent.serverLabel} + + - - {agent.cwd} - + + {agent.cwd} + - - - - - {providerLabel} - - + + + + + {providerLabel} + + - - - - {statusLabel} - - - - - - {timeAgo} + + + + {statusLabel} - - ); - })} - - ))} + + + {timeAgo} + + + + + ); + })} ({ 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, diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 2d3e773fa..ba7cbff4b 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -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(); - const daemonOrder = new Map(); - 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]); } diff --git a/plan.md b/plan.md index 35b5fc5ab..c13abad2d 100644 --- a/plan.md +++ b/plan.md @@ -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.**