feat(app): tappable fleet stat cards open a filtered agent list

The Running / Waiting / Done cards now navigate to a new agent-list
screen pre-filtered to that bucket, using exactly the same grouping as
the counts so the number tapped is the number listed. The list shows
every agent row the API knows — an agent that hasn't dropped a
checkmark yet is visible the moment it spawns, with a status badge, its
age, and a live last-output line while it works. Rows open the agent
detail screen, whose back button now returns to wherever the detail was
opened from (fleet or the list).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01731mKtVzsfeT4Vi3TvkR48
This commit is contained in:
Claude
2026-08-13 18:44:33 +00:00
parent fc47f02c44
commit e7d08ef3de
7 changed files with 260 additions and 23 deletions
+2
View File
@@ -49,6 +49,7 @@ import { ClaudeLoginScreen } from "./src/screens/manage/ClaudeLoginScreen";
import { ServerConfigProvider, useServerConfig } from "./src/state/ServerConfig";
import { ConnectScreen } from "./src/screens/ConnectScreen";
import { FleetScreen } from "./src/screens/FleetScreen";
import { AgentListScreen } from "./src/screens/AgentListScreen";
import { AgentDetailScreen } from "./src/screens/AgentDetailScreen";
import { AnswerScreen } from "./src/screens/AnswerScreen";
import { SpawnScreen } from "./src/screens/SpawnScreen";
@@ -63,6 +64,7 @@ function Router() {
const screens: Record<ScreenName, () => React.JSX.Element> = {
connect: ConnectScreen,
fleet: FleetScreen,
agentList: AgentListScreen,
detail: AgentDetailScreen,
answer: AnswerScreen,
spawn: SpawnScreen,
+2 -1
View File
@@ -23,7 +23,8 @@ npm run ios # opens the iOS simulator (requires Xcode)
| Screen | File | What it does |
| --- | --- | --- |
| Fleet (home) | `src/screens/FleetScreen.tsx` | Stat cards, "Waiting on you" list → Answer, "Recent checkmarks" → detail |
| Fleet (home) | `src/screens/FleetScreen.tsx` | Tappable stat cards → filtered agent list, "Waiting on you" → Answer, "Recent checkmarks" → detail |
| Agent list | `src/screens/AgentListScreen.tsx` | Every agent (checkmark or not) with All / Running / Waiting / Done filters → detail |
| Agent detail | `src/screens/AgentDetailScreen.tsx` | Checkmark / Events / Log segmented control, live headless run event stream, meta table (incl. model backend + worker), Answer / Kill |
| Answer | `src/screens/AnswerScreen.tsx` | Question, tappable quick replies, reply field + **Send & resume** |
| Spawn | `src/screens/SpawnScreen.tsx` | Project select, model backend select, task field, Spawn |
+7 -2
View File
@@ -25,6 +25,7 @@ export function AgentDetailScreen() {
openAnswer,
detailTab,
setDetailTab,
detailReturnTo,
models,
selectedAgent,
selectedCheckmark,
@@ -38,7 +39,11 @@ export function AgentDetailScreen() {
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
<View style={{ height: insets.top }} />
<View style={styles.content}>
<PageHeader leading="back" onLeadingPress={() => go("fleet")} title="Agent" />
<PageHeader
leading="back"
onLeadingPress={() => go(detailReturnTo)}
title="Agent"
/>
<Text style={[text.body, { color: colors.textMuted }]}>
This agent is no longer in the fleet.
</Text>
@@ -95,7 +100,7 @@ export function AgentDetailScreen() {
>
<PageHeader
leading="back"
onLeadingPress={() => go("fleet")}
onLeadingPress={() => go(detailReturnTo)}
agentId={agent.name}
badge={{ tone: statusTone(agent.status), label: statusLabel(agent.status) }}
/>
+159
View File
@@ -0,0 +1,159 @@
import React, { useMemo } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { text } from "../theme/tokens";
import { useTheme } from "../theme/useTheme";
import { Badge } from "../components/Badge";
import { Chip } from "../components/Chip";
import { Icon } from "../components/Icon";
import { PageHeader } from "../components/PageHeader";
import { Card, Divider, Mono, SectionLabel } from "../components/primitives";
import { useAppState, type AgentFilter } from "../state/AppState";
import { statusLabel, statusTone, timeAgo } from "../api/format";
import type { Agent } from "../api/client";
/**
* The full agent roster, reached by tapping a fleet stat card. Unlike the fleet's
* "Recent checkmarks" list this shows every agent row the API knows — an agent that
* hasn't dropped a checkmark yet is still visible here the moment it spawns. The
* filter buckets use exactly the same grouping as the stat-card counts, so the
* number tapped is the number listed.
*/
const FILTERS: { key: AgentFilter; label: string }[] = [
{ key: "all", label: "All" },
{ key: "running", label: "Running" },
{ key: "waiting", label: "Waiting" },
{ key: "done", label: "Done" },
];
function isRunning(a: Agent): boolean {
const s = a.status.toLowerCase();
return s === "working" || s === "running";
}
function isDone(a: Agent): boolean {
const s = a.status.toLowerCase();
return s === "done" || s === "failed";
}
export function AgentListScreen() {
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const { go, openDetail, agents, waiting, agentFilter, setAgentFilter } =
useAppState();
// Waiting matches the fleet's "waiting on you" derivation (paused agents plus
// open checkmark questions), keyed the same way the store builds it.
const waitingKeys = useMemo(
() => new Set(waiting.map((w) => `${w.project}/${w.name}`)),
[waiting],
);
const rows = useMemo(() => {
switch (agentFilter) {
case "running":
return agents.filter(isRunning);
case "waiting":
return agents.filter((a) => waitingKeys.has(`${a.project_id}/${a.name}`));
case "done":
return agents.filter(isDone);
default:
return agents;
}
}, [agents, agentFilter, waitingKeys]);
return (
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
<View style={{ height: insets.top }} />
<View style={styles.header}>
<PageHeader leading="back" onLeadingPress={() => go("fleet")} title="Agents" />
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filters}
>
{FILTERS.map((f) => (
<Chip
key={f.key}
label={f.label}
selected={agentFilter === f.key}
onPress={() => setAgentFilter(f.key)}
/>
))}
</ScrollView>
</View>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={[styles.body, { paddingBottom: insets.bottom + 24 }]}
showsVerticalScrollIndicator={false}
>
<SectionLabel style={{ marginBottom: 8 }}>
{`${rows.length} agent${rows.length === 1 ? "" : "s"}`}
</SectionLabel>
{rows.length === 0 ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>
{agentFilter === "all"
? "No agents yet — spawn one from the Fleet screen."
: `No ${agentFilter} agents right now.`}
</Text>
) : (
<Card>
{rows.map((a, i) => (
<View key={`${a.project_id}/${a.name}`}>
{i > 0 && <Divider />}
<Pressable
style={({ pressed }) => [
styles.row,
pressed && { backgroundColor: colors.surfaceSunken },
]}
onPress={() => openDetail(a.project_id, a.name, "agentList")}
>
<View style={{ flex: 1, minWidth: 0, gap: 2 }}>
<View style={styles.titleRow}>
<Mono style={{ fontSize: 13, color: colors.textHeading }}>
{a.name}
</Mono>
<Badge tone={statusTone(a.status)}>{statusLabel(a.status)}</Badge>
</View>
<Text style={[text.caption, { color: colors.textMuted }]}>
{a.project_id}
{a.role ? ` · ${a.role}` : ""}
{` · started ${timeAgo(a.created_at)} ago`}
</Text>
{a.last_output?.trim() && isRunning(a) ? (
<Mono
numberOfLines={1}
style={{ fontSize: 11.5, color: colors.textMuted }}
>
{a.last_output.trim().split("\n").pop()}
</Mono>
) : null}
</View>
<Icon name="chevronRight" size={16} color={colors.ink4} />
</Pressable>
</View>
))}
</Card>
)}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { paddingTop: 8, paddingHorizontal: 20 },
filters: { flexDirection: "row", gap: 8, paddingBottom: 12, paddingRight: 20 },
body: { paddingHorizontal: 20 },
row: {
flexDirection: "row",
alignItems: "center",
gap: 12,
paddingVertical: 12,
paddingHorizontal: 16,
minHeight: 44,
},
titleRow: { flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" },
});
+39 -15
View File
@@ -22,6 +22,7 @@ import {
import { TabBar } from "../components/TabBar";
import {
useAppState,
type AgentFilter,
type RecentItem,
type WaitingItem,
} from "../state/AppState";
@@ -29,15 +30,25 @@ import {
export function FleetScreen() {
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const { go, openAnswer, openDetail, waiting, recent, counts, loading, error, refresh } =
useAppState();
const {
go,
openAnswer,
openDetail,
openAgentList,
waiting,
recent,
counts,
loading,
error,
refresh,
} = useAppState();
const empty = waiting.length === 0 && recent.length === 0;
const stats = [
{ label: "Running", value: counts.running, tint: colors.textHeading },
{ label: "Waiting", value: counts.waiting, tint: colors.warning },
{ label: "Done", value: counts.done, tint: colors.textHeading },
const stats: { label: string; value: number; tint: string; filter: AgentFilter }[] = [
{ label: "Running", value: counts.running, tint: colors.textHeading, filter: "running" },
{ label: "Waiting", value: counts.waiting, tint: colors.warning, filter: "waiting" },
{ label: "Done", value: counts.done, tint: colors.textHeading, filter: "done" },
];
return (
@@ -61,14 +72,27 @@ export function FleetScreen() {
<View style={styles.statsRow}>
{stats.map((s) => (
<Card key={s.label} style={styles.statCard}>
<Text style={[text.caption, { color: colors.textMuted }]}>
{s.label}
</Text>
<Text style={[styles.statValue, { color: s.tint }]}>
{s.value}
</Text>
</Card>
<Pressable
key={s.label}
style={{ flex: 1 }}
onPress={() => openAgentList(s.filter)}
>
{({ pressed }) => (
<Card
style={[
styles.statCard,
pressed && { backgroundColor: colors.surfaceSunken },
]}
>
<Text style={[text.caption, { color: colors.textMuted }]}>
{s.label}
</Text>
<Text style={[styles.statValue, { color: s.tint }]}>
{s.value}
</Text>
</Card>
)}
</Pressable>
))}
</View>
@@ -219,7 +243,7 @@ const styles = StyleSheet.create({
marginBottom: 20,
},
statsRow: { flexDirection: "row", gap: 12, marginBottom: 20 },
statCard: { flex: 1, paddingVertical: 14, paddingHorizontal: 16 },
statCard: { paddingVertical: 14, paddingHorizontal: 16 },
statValue: {
fontFamily: fonts.monoSemiBold,
fontSize: 24,
+43 -5
View File
@@ -38,6 +38,7 @@ import { useServerConfig } from "./ServerConfig";
export type Screen =
| "connect"
| "fleet"
| "agentList"
| "detail"
| "answer"
| "spawn"
@@ -62,6 +63,8 @@ export type Screen =
| "claudeLogin";
export type DetailTab = "state" | "events" | "log";
/* Status buckets for the agent list, matching the fleet counts exactly. */
export type AgentFilter = "all" | "running" | "waiting" | "done";
export type BadgeTone = "neutral" | "positive" | "warning" | "danger";
export type RecentTone = "positive" | "danger";
@@ -114,13 +117,21 @@ interface AppStateValue {
go: (screen: Screen) => void;
setDetailTab: (tab: DetailTab) => void;
setLogFilter: (f: string) => void;
openDetail: (project: string, name: string) => void;
openDetail: (project: string, name: string, from?: Screen) => void;
openAnswer: (project: string, name: string) => void;
/* Open the full agent list pre-filtered (the fleet stat cards tap through here). */
openAgentList: (filter: AgentFilter) => void;
agentFilter: AgentFilter;
setAgentFilter: (f: AgentFilter) => void;
/* Where the detail screen's back button returns to (fleet or the agent list). */
detailReturnTo: Screen;
// Fleet data.
loading: boolean;
error: string | null;
projects: Project[];
/* Every agent across every project, flat — checkmark or not. */
agents: Agent[];
/* Registered model backends (the spawn/schedule dropdown next to the subscription). */
models: ClaudeModel[];
/* Recurring agent spawns, across all projects. */
@@ -213,6 +224,8 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
const [screen, setScreen] = useState<Screen>("fleet");
const [detailTab, setDetailTab] = useState<DetailTab>("state");
const [logFilter, setLogFilter] = useState<string>("all");
const [agentFilter, setAgentFilter] = useState<AgentFilter>("all");
const [detailReturnTo, setDetailReturnTo] = useState<Screen>("fleet");
const [selected, setSelected] = useState<Selected | null>(null);
const resetData = useCallback(() => {
@@ -405,6 +418,13 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
return { running, waiting: waiting.length, done };
}, [agentsByProject, waiting]);
const agents = useMemo<Agent[]>(() => {
const flat = Object.values(agentsByProject).flat();
return [...flat].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
}, [agentsByProject]);
const globalLog = useMemo<GlobalLogItem[]>(() => {
const rows: GlobalLogItem[] = [];
for (const [pid, list] of Object.entries(agentsByProject)) {
@@ -511,10 +531,19 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
: [];
// ---- Navigation helpers --------------------------------------------------
const openDetail = useCallback((project: string, name: string) => {
setSelected({ project, name });
setDetailTab("state");
setScreen("detail");
const openDetail = useCallback(
(project: string, name: string, from: Screen = "fleet") => {
setSelected({ project, name });
setDetailTab("state");
setDetailReturnTo(from);
setScreen("detail");
},
[],
);
const openAgentList = useCallback((filter: AgentFilter) => {
setAgentFilter(filter);
setScreen("agentList");
}, []);
const openAnswer = useCallback((project: string, name: string) => {
@@ -641,10 +670,15 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
setLogFilter,
openDetail,
openAnswer,
openAgentList,
agentFilter,
setAgentFilter,
detailReturnTo,
loading,
error,
projects,
agents,
models,
schedules,
memory,
@@ -675,9 +709,13 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
logFilter,
openDetail,
openAnswer,
openAgentList,
agentFilter,
detailReturnTo,
loading,
error,
projects,
agents,
models,
schedules,
memory,