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.**