paseo-follow-up 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/OVERVIEW.md +19 -7
  2. package/README.md +70 -79
  3. package/THIRD_PARTY_NOTICES.md +33 -0
  4. package/assets/LICENSE.lucide +43 -0
  5. package/assets/icon.png +0 -0
  6. package/assets/icon.svg +1 -0
  7. package/client/button-visibility.ts +2 -1
  8. package/client/chat.tsx +163 -0
  9. package/client/display.tsx +12 -17
  10. package/client/header.tsx +92 -0
  11. package/client/keys.ts +3 -0
  12. package/client/kit.tsx +119 -0
  13. package/client/large.tsx +58 -0
  14. package/client/live.tsx +159 -0
  15. package/client/motion.tsx +93 -0
  16. package/client/needs-you.tsx +121 -0
  17. package/client/overview.tsx +111 -16
  18. package/client/panel.tsx +257 -68
  19. package/client/pr-controls.tsx +50 -33
  20. package/client/pr-sheet.tsx +63 -0
  21. package/client/questions.tsx +307 -0
  22. package/client/settings.tsx +55 -20
  23. package/client/spec.tsx +113 -0
  24. package/client/stacks.tsx +128 -0
  25. package/client/tasks.tsx +113 -22
  26. package/client/theme.ts +69 -0
  27. package/docs/screenshots/follow-up-chat.png +0 -0
  28. package/docs/screenshots/follow-up-hero.png +0 -0
  29. package/docs/screenshots/follow-up-large.png +0 -0
  30. package/docs/screenshots/follow-up-mobile.png +0 -0
  31. package/docs/screenshots/follow-up-overview.png +0 -0
  32. package/docs/screenshots/follow-up-pill.png +0 -0
  33. package/docs/screenshots/follow-up-pr.png +0 -0
  34. package/docs/screenshots/follow-up-questions.png +0 -0
  35. package/docs/screenshots/follow-up-states.png +0 -0
  36. package/index.client.tsx +47 -16
  37. package/index.server.ts +53 -0
  38. package/package.json +4 -2
  39. package/paseo-plugin.json +11 -3
  40. package/server/agent-hook.mjs +2 -0
  41. package/server/daemon.ts +37 -6
  42. package/server/discovery.ts +6 -3
  43. package/server/engine.ts +39 -0
  44. package/server/hook-source.ts +1 -1
  45. package/server/mcp-source.ts +1 -1
  46. package/server/mcp.mjs +12 -2
  47. package/server/questions.ts +112 -0
  48. package/server/spec.ts +102 -0
  49. package/shared/activity.ts +56 -0
  50. package/shared/attention.ts +212 -0
  51. package/shared/chat.ts +130 -0
  52. package/shared/contracts.ts +21 -0
  53. package/shared/display.ts +2 -1
  54. package/shared/errors.ts +5 -0
  55. package/shared/model.ts +10 -0
  56. package/shared/pr-actions.ts +8 -1
  57. package/shared/questions.ts +123 -0
  58. package/shared/spec.ts +46 -0
  59. package/skills/follow-through/SKILL.md +4 -0
  60. package/client/components.tsx +0 -58
  61. package/client/navigation.tsx +0 -23
  62. package/docs/screenshots/follow-up-settings.png +0 -0
  63. package/docs/screenshots/follow-up-workspace.png +0 -0
@@ -0,0 +1,121 @@
1
+ import { Text, View } from "react-native";
2
+ import { Icon, useToast } from "@getpaseo/plugin/client/ui";
3
+ import { useRpc } from "@getpaseo/plugin/client";
4
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
5
+ import { checkPrActionRpc, controlRpc, sendRpc } from "../shared/contracts";
6
+ import { needsYou, shortRefs, type NeedItem } from "../shared/attention";
7
+ import type { Workspace } from "../shared/model";
8
+ import { Button, Card, CardRow, errorText, text, type Theme } from "./kit";
9
+ import type { Colors } from "./theme";
10
+ import { meanwhile } from "./questions";
11
+
12
+ import { overviewKey, workspaceKey } from "./keys";
13
+ export { overviewKey, workspaceKey };
14
+
15
+ function glyph(item: NeedItem, colors: Colors): { name: string; color: string } {
16
+ switch (item.kind) {
17
+ case "hold": return { name: "CirclePause", color: colors.statusWarning };
18
+ case "retry": return { name: "OctagonAlert", color: colors.statusDanger };
19
+ case "check": return { name: "CircleHelp", color: colors.statusWarning };
20
+ case "decision": return { name: "MessageCircleQuestion", color: colors.statusWarning };
21
+ case "question": return { name: item.state === "stopped" ? "CirclePause" : "MessageCircleQuestion", color: colors.statusWarning };
22
+ case "answer": return { name: "TriangleAlert", color: colors.statusWarning };
23
+ case "send": return { name: "Send", color: colors.statusWarning };
24
+ case "merge": return { name: "GitMerge", color: colors.statusSuccess };
25
+ case "reviewers": return { name: "UserPlus", color: colors.foregroundMuted };
26
+ }
27
+ }
28
+
29
+ export type NeedActions = {
30
+ openAgent?: (agentId: string) => void;
31
+ openPr?: (prKey: string, intent: "merge" | "reviewers") => void;
32
+ openQuestion?: (questionId: string) => void;
33
+ after?: () => void;
34
+ };
35
+
36
+ /** The developer's queue: one row per decision, each with its single action inline. */
37
+ export function NeedsYouList({ workspace: w, theme, compact, actions, now = Date.now(), flush = false, skip = [] }: { workspace: Workspace; theme: Theme; compact: boolean; actions: NeedActions; now?: number; flush?: boolean; skip?: NeedItem["kind"][] }) {
38
+ const c = theme.colors; const t = text(c);
39
+ const toast = useToast();
40
+ const cache = useQueryClient();
41
+ const control = useRpc(controlRpc); const send = useRpc(sendRpc); const check = useRpc(checkPrActionRpc);
42
+ const refresh = () => { void cache.invalidateQueries({ queryKey: workspaceKey(w.id) }); void cache.invalidateQueries({ queryKey: overviewKey }); };
43
+ const fail = (error: Error) => { toast.error(errorText(error)); refresh(); };
44
+ const resume = useMutation({ mutationFn: () => control({ workspaceId: w.id, clearHold: true }), onSuccess: () => { toast.show("Follow-up resumed", { variant: "success" }); refresh(); actions.after?.(); }, onError: fail });
45
+ const retry = useMutation({ mutationFn: (batchId: string) => control({ workspaceId: w.id, retryBatchId: batchId }), onSuccess: () => { toast.show("Repairs queued again"); refresh(); actions.after?.(); }, onError: fail });
46
+ // Say what actually happened to the batch: delivery can be deferred or unconfirmed without an error.
47
+ const deliver = useMutation({ mutationFn: (batchId: string) => send({ workspaceId: w.id, batchId }), onSuccess: (result, batchId) => {
48
+ const b = result.batches.find(x => x.id === batchId);
49
+ if (!b || ["sent", "sending", "completed"].includes(b.state)) toast.show("Sent to the main agent", { variant: "success" });
50
+ else if (b.state === "uncertain") toast.show("Delivery wasn't confirmed. Check the agent before retrying.", { variant: "warning" });
51
+ else if (b.state === "pending") toast.show("Queued. It goes out when the agent is free.", { variant: "info" });
52
+ else toast.error(b.result || "The repairs couldn't be sent.");
53
+ refresh(); actions.after?.();
54
+ }, onError: fail });
55
+ const inspect = useMutation({ mutationFn: (actionId: string) => check({ workspaceId: w.id, actionId }), onSuccess: reply => { toast.show(reply.result, { variant: reply.state === "failed" ? "warning" : "info" }); refresh(); }, onError: fail });
56
+ const items = needsYou(w, now).filter(item => !skip.includes(item.kind));
57
+ if (!items.length) return null;
58
+ const busy = resume.isPending || retry.isPending || deliver.isPending || inspect.isPending;
59
+ const openMain = actions.openAgent ? () => actions.openAgent!(w.mainAgentId) : undefined;
60
+ const action = (item: NeedItem) => {
61
+ switch (item.kind) {
62
+ case "hold": return <Button theme={theme} compact={compact} kind="primary" icon="Play" title="Resume follow-up" loading={resume.isPending} disabled={busy} onPress={() => resume.mutate()} />;
63
+ case "retry": return item.exhausted
64
+ ? openMain && <Button theme={theme} compact={compact} icon="MessageSquare" title="Open main agent" onPress={openMain} />
65
+ : <>
66
+ <Button theme={theme} compact={compact} icon="RotateCcw" title="Retry" accessibilityLabel="Queue the repairs again" loading={retry.isPending} disabled={busy} onPress={() => retry.mutate(item.batchId)} />
67
+ {openMain && <Button theme={theme} compact={compact} kind="ghost" icon="MessageSquare" title="Open agent" accessibilityLabel="Open main agent" onPress={openMain} />}
68
+ </>;
69
+ case "check": return <Button theme={theme} compact={compact} icon="RefreshCw" title="Check state" loading={inspect.isPending} disabled={busy} onPress={() => inspect.mutate(item.actionId)} />;
70
+ case "decision": return openMain && <Button theme={theme} compact={compact} icon="MessageSquare" title="Answer in chat" onPress={openMain} />;
71
+ case "question": return actions.openQuestion && <Button theme={theme} compact={compact} icon="MessageCircleReply" title="Answer" accessibilityLabel={`Answer ${item.title}`} onPress={() => actions.openQuestion!(item.questionId)} />;
72
+ case "answer": return actions.openQuestion && <Button theme={theme} compact={compact} icon="Eye" title="Check" accessibilityLabel={`Check the ${item.questionId} answer`} onPress={() => actions.openQuestion!(item.questionId)} />;
73
+ case "send": return <Button theme={theme} compact={compact} kind="primary" icon="Send" title="Send to main agent" accessibilityLabel={`Send ${item.tasks.length} repairs to the main agent`} loading={deliver.isPending} disabled={busy || !!item.blocker} onPress={() => deliver.mutate(item.batchId)} />;
74
+ case "merge": return actions.openPr && <Button theme={theme} compact={compact} icon="GitMerge" title={`Merge #${item.number}`} accessibilityLabel={`Review and merge pull request ${item.number}`} onPress={() => actions.openPr!(item.prKey, "merge")} />;
75
+ case "reviewers": return actions.openPr && <Button theme={theme} compact={compact} icon="UserPlus" title="Add reviewers" onPress={() => actions.openPr!(item.prKey, "reviewers")} />;
76
+ }
77
+ };
78
+ return <View>
79
+ {items.map((item, index) => {
80
+ const g = glyph(item, c);
81
+ const button = action(item);
82
+ return <CardRow key={item.key} theme={theme} first={index === 0 && flush} style={flush ? { paddingHorizontal: 0 } : undefined}>
83
+ <View style={{ flexDirection: "row", gap: 9 }}>
84
+ <View style={{ paddingTop: 2 }}><Icon name={g.name} size={14} color={g.color} /></View>
85
+ <View style={{ flex: 1, minWidth: 0, gap: 3 }}>
86
+ <Text style={t.body}>{item.title}</Text>
87
+ {"detail" in item && !!item.detail && <Text numberOfLines={item.kind === "question" ? 2 : 3} style={t.meta}>{item.detail}</Text>}
88
+ {item.kind === "question" && <QuestionState workspace={w} questionId={item.questionId} theme={theme} />}
89
+ {item.kind === "send" && <View style={{ gap: 2 }}>
90
+ {item.tasks.slice(0, 4).map((task, i) => <Text key={i} numberOfLines={2} style={t.meta}><Text style={{ fontVariant: ["tabular-nums"] }}>{`${i + 1}. `}</Text>{shortRefs(task, w)}</Text>)}
91
+ {item.tasks.length > 4 && <Text style={t.meta}>{`and ${item.tasks.length - 4} more`}</Text>}
92
+ </View>}
93
+ {(button || (item.kind === "send" && item.blocker)) && <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8, marginTop: 5 }}>
94
+ {button}
95
+ {item.kind === "send" && item.blocker && <Text style={[t.small, { flexShrink: 1 }]}>{item.blocker}</Text>}
96
+ </View>}
97
+ </View>
98
+ </View>
99
+ </CardRow>;
100
+ })}
101
+ </View>;
102
+ }
103
+
104
+ function QuestionState({ workspace: w, questionId, theme }: { workspace: Workspace; questionId: string; theme: Theme }) {
105
+ const c = theme.colors;
106
+ const q = w.questions.find(x => x.id === questionId);
107
+ if (!q) return null;
108
+ const state = meanwhile(w, q);
109
+ return <View style={{ flexDirection: "row", gap: 5 }}>
110
+ <View style={{ paddingTop: 2 }}><Icon name={state.icon} size={11} color={state.color(c)} /></View>
111
+ <Text numberOfLines={4} style={[text(c).small, { flex: 1, color: state.color(c) }]}>{state.text}</Text>
112
+ </View>;
113
+ }
114
+
115
+ export function NeedsYouCard({ workspace, theme, compact, actions, now }: { workspace: Workspace; theme: Theme; compact: boolean; actions: NeedActions; now: number }) {
116
+ const count = needsYou(workspace, now).length;
117
+ if (!count) return null;
118
+ return <Card theme={theme} compact={compact} icon="CircleAlert" title="Needs you" tone="attention" count={count}>
119
+ <NeedsYouList workspace={workspace} theme={theme} compact={compact} actions={actions} now={now} />
120
+ </Card>;
121
+ }
@@ -1,28 +1,123 @@
1
1
  import { useState } from "react";
2
- import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
3
- import { useRpc, type PluginSurfaceProps } from "@getpaseo/plugin/client";
2
+ import { Pressable, ScrollView, Text, View } from "react-native";
3
+ import { useRpc, type PluginScreenProps } from "@getpaseo/plugin/client";
4
4
  import { useQuery } from "@tanstack/react-query";
5
- import { Icon } from "@getpaseo/plugin/client/react-native";
5
+ import { Icon } from "@getpaseo/plugin/client/ui";
6
6
  import { listRpc } from "../shared/contracts";
7
- import { summary, type Status } from "../shared/model";
8
- import { Button, colorFor } from "./components";
9
- import { overviewKey } from "./panel";
7
+ import type { Status, Workspace } from "../shared/model";
8
+ import { followUpObligations } from "../shared/follow-up";
9
+ import { ago, liveLine, needsYou, overviewStatus, stackBar, statusGroups } from "../shared/attention";
10
+ import { summary } from "../shared/model";
11
+ import { Button, IconButton, text, useNow, type Theme } from "./kit";
12
+ import { StackBar } from "./header";
13
+ import { Skeleton } from "./motion";
14
+ import { raised, type Colors } from "./theme";
15
+ import { overviewKey } from "./needs-you";
16
+ import { SpecChip } from "./spec";
10
17
 
11
- export function FollowOverview({ theme, layout, navigation }: PluginSurfaceProps) {
18
+ // Accent stays for actions: working is plain foreground, waiting is muted, and only Done is green.
19
+ export const statusColor = (c: Colors, status: Status) => status === "done" ? c.statusSuccess : status === "needs-you" ? c.statusWarning : status === "handling" ? c.foreground : c.foregroundMuted;
20
+ const statusIcon = (status: Status) => status === "done" ? "CircleCheck" : status === "needs-you" ? "CircleAlert" : status === "handling" ? "LoaderCircle" : status === "waiting" ? "Clock" : "GitPullRequest";
21
+
22
+ function reasonFor(w: Workspace, status: Status, now: number): string {
23
+ if (status === "needs-you") {
24
+ const items = needsYou(w, now);
25
+ const first = items[0]?.kind === "question" && items[0].state === "stopped" ? `Agent stopped · ${items[0].title}` : items[0]?.title;
26
+ return items.length > 1 ? `${first} · and ${items.length - 1} more` : first ?? summary(w, now).reason;
27
+ }
28
+ if (status === "done") return w.completion?.summary ?? "Ready to archive";
29
+ return liveLine(w, now).text;
30
+ }
31
+
32
+ function WorkspaceRow({ w, theme, compact, now, onOpen, first }: { w: Workspace; theme: Theme; compact: boolean; now: number; onOpen?: () => void; first: boolean }) {
33
+ const c = theme.colors; const t = text(c);
34
+ const status = overviewStatus(w, now);
35
+ const bar = stackBar(w);
36
+ const left = followUpObligations(w).filter(o => !["done", "waived"].includes(o.status)).length;
37
+ const meta = [bar.total ? bar.merged === bar.total ? `${bar.total} merged` : `${bar.merged} of ${bar.total} merged` : "No PRs yet", left ? `${left} task${left === 1 ? "" : "s"} left` : null, w.enable_auto_sends ? "Auto-send on" : null].filter(Boolean).join(" · ");
38
+ return <Pressable accessibilityRole="button" accessibilityLabel={`${w.title}. ${statusGroups.find(g => g.status === status)?.title}. Open workspace`} disabled={!onOpen} onPress={onOpen}
39
+ style={state => ({ flexDirection: "row", gap: 12, paddingHorizontal: 14, paddingVertical: 12, borderTopWidth: first ? 0 : 1, borderTopColor: c.border, backgroundColor: state.pressed ? c.surface2 : (state as { hovered?: boolean }).hovered ? c.surface1 : "transparent" })}>
40
+ <View style={{ paddingTop: 2 }}><Icon name={statusIcon(status)} size={16} color={statusColor(c, status)} /></View>
41
+ <View style={{ flex: 1, minWidth: 0, gap: 4 }}>
42
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
43
+ <Text numberOfLines={1} style={[t.label, { flex: 1, fontSize: 14, lineHeight: 19 }]}>{w.title}{w.archived ? <Text style={{ color: c.foregroundMuted, fontWeight: "400" }}> · archived</Text> : null}</Text>
44
+ {!!w.spec?.url && <View style={{ marginVertical: -4 }}><SpecChip workspace={w} theme={theme} compact={false} /></View>}
45
+ <Text style={[t.small, { fontVariant: ["tabular-nums"] }]}>{ago(w.updatedAt, now)}</Text>
46
+ </View>
47
+ <Text numberOfLines={2} style={[t.meta, { color: status === "needs-you" ? c.foreground : c.foregroundMuted }]}>{reasonFor(w, status, now)}</Text>
48
+ <View style={{ flexDirection: compact ? "column" : "row", alignItems: compact ? "stretch" : "center", gap: compact ? 6 : 10, marginTop: 3 }}>
49
+ {bar.total > 0 && <View style={{ width: compact ? "100%" : 140 }}><StackBar workspace={w} theme={theme} height={4} /></View>}
50
+ <Text numberOfLines={1} style={[t.small, { fontVariant: ["tabular-nums"] }]}>{meta}</Text>
51
+ </View>
52
+ </View>
53
+ {onOpen && <View style={{ paddingTop: 2 }}><Icon name="ChevronRight" size={15} color={c.foregroundMuted} /></View>}
54
+ </Pressable>;
55
+ }
56
+
57
+ export function FollowOverview({ theme, layout, navigation, openSettings }: PluginScreenProps & { openSettings: () => void }) {
58
+ const c = theme.colors; const t = text(c);
12
59
  const list = useRpc(listRpc);
60
+ const now = useNow(30_000);
13
61
  const [showArchived, setShowArchived] = useState(false);
62
+ const [filter, setFilter] = useState<Status | "all">("all");
14
63
  const query = useQuery({ queryKey: overviewKey, queryFn: () => list({}), refetchInterval: 5000 });
15
- const groups: { status: Status; title: string }[] = [{ status: "needs-you", title: "Needs you" }, { status: "handling", title: "Agent handling" }, { status: "waiting", title: "Waiting" }, { status: "active", title: "Active" }, { status: "done", title: "Done · ready to archive" }];
16
- const workspaces = (query.data?.workspaces ?? []).filter(w => !w.archived || showArchived);
17
- return <ScrollView style={{ flex: 1, backgroundColor: theme.colors.surface0 }} contentContainerStyle={{ padding: layout.compact ? 16 : 24, gap: 8, maxWidth: 960, width: "100%", alignSelf: "center", paddingBottom: 40 }}>
18
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}><View style={{ flex: 1, gap: 7 }}><Text style={{ color: theme.colors.foreground, fontSize: 22, fontWeight: "600" }}>Workspace follow-up</Text><Text style={{ color: theme.colors.foregroundMuted, fontSize: 13 }}>What needs you, what the agents are handling, and what is finished.</Text></View><Button title="Refresh" icon="RefreshCw" theme={theme} compact={layout.compact} onPress={() => void query.refetch()} /></View>
19
- {query.isPending && <ActivityIndicator color={theme.colors.accent} />}{(query.error || query.data?.error) && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger }}>Could not refresh all workspace state. Try Refresh.</Text>}
20
- {!query.isPending && !query.error && !workspaces.length && <View style={{ marginTop: 28, gap: 9 }}><Text style={{ color: theme.colors.foreground, fontSize: 16 }}>No workspaces tracked yet</Text><Text style={{ color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 20 }}>Open Follow-up in a workspace and click Track this workspace. Tracking and messages stay scoped to workspaces you choose.</Text></View>}
64
+ const compact = layout.compact;
65
+ const all = query.data?.workspaces ?? [];
66
+ const archived = all.filter(w => w.archived).length;
67
+ const workspaces = all.filter(w => !w.archived || showArchived);
68
+ const counts = new Map<Status, number>(statusGroups.map(g => [g.status, workspaces.filter(w => overviewStatus(w, now) === g.status).length]));
69
+ const need = counts.get("needs-you") ?? 0;
70
+ const groups = statusGroups.filter(g => filter === "all" || g.status === filter);
71
+ const chip = (id: Status | "all", label: string, count: number, color?: string) => {
72
+ const selected = filter === id;
73
+ return <Pressable key={id} accessibilityRole="tab" accessibilityState={{ selected }} aria-selected={selected} accessibilityLabel={`${label}, ${count}`} onPress={() => setFilter(id)}
74
+ style={state => ({ flexDirection: "row", alignItems: "center", gap: 6, minHeight: compact ? 40 : 30, paddingHorizontal: 11, borderRadius: 999, backgroundColor: selected ? c.surface2 : (state as { hovered?: boolean }).hovered ? c.surface1 : "transparent", borderWidth: 1, borderColor: selected ? c.border : "transparent" })}>
75
+ {color && <View style={{ width: 7, height: 7, borderRadius: 4, backgroundColor: color }} />}
76
+ <Text style={{ color: selected ? c.foreground : c.foregroundMuted, fontSize: 12, fontWeight: "600" }}>{label}</Text>
77
+ <Text style={{ color: c.foregroundMuted, fontSize: 12, fontVariant: ["tabular-nums"] }}>{count}</Text>
78
+ </Pressable>;
79
+ };
80
+ return <ScrollView style={{ flex: 1, backgroundColor: c.surface0 }} contentContainerStyle={{ padding: compact ? 16 : 28, paddingBottom: 48, maxWidth: 880, width: "100%", alignSelf: "center", gap: 14 }}>
81
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
82
+ <View style={{ flex: 1, gap: 4 }}>
83
+ <Text accessibilityRole="header" style={[t.title, { fontSize: 20, lineHeight: 26 }]}>Tracked workspaces</Text>
84
+ <Text style={t.meta}>{query.isPending ? "Loading…" : workspaces.length ? `${workspaces.length} workspace${workspaces.length === 1 ? "" : "s"}${need ? "" : " · nothing needs you"}` : "None yet"}{need ? <Text style={{ color: c.statusWarning, fontWeight: "600" }}>{` · ${need} need${need === 1 ? "s" : ""} you`}</Text> : null}</Text>
85
+ </View>
86
+ <IconButton icon="RefreshCw" label="Refresh" onPress={() => void query.refetch()} theme={theme} compact={compact} spinning={query.isRefetching} />
87
+ <IconButton icon="Settings" label="Follow-up settings" onPress={openSettings} theme={theme} compact={compact} />
88
+ </View>
89
+ {(query.error || query.data?.error) && <View accessibilityRole="alert" style={{ flexDirection: "row", gap: 8, padding: 10, borderRadius: 8, borderWidth: 1, borderColor: c.statusWarning }}>
90
+ <View style={{ paddingTop: 2 }}><Icon name="TriangleAlert" size={14} color={c.statusWarning} /></View>
91
+ <Text style={[t.meta, { flex: 1, color: c.statusWarning }]}>{query.error ? "Couldn't refresh tracked workspaces. Showing what was last loaded." : query.data?.error}</Text>
92
+ </View>}
93
+ {workspaces.length > 0 && <ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ flexGrow: 0, marginHorizontal: compact ? -16 : 0 }} contentContainerStyle={{ gap: 4, paddingHorizontal: compact ? 16 : 0 }}>
94
+ <View accessibilityRole="tablist" style={{ flexDirection: "row", gap: 4 }}>
95
+ {chip("all", "All", workspaces.length)}
96
+ {statusGroups.filter(g => (counts.get(g.status) ?? 0) > 0).map(g => chip(g.status, g.short, counts.get(g.status) ?? 0, statusColor(c, g.status)))}
97
+ </View>
98
+ </ScrollView>}
99
+ {query.isPending && <View style={{ borderRadius: 10, ...raised(c), padding: 14, gap: 12 }}>{[0, 1, 2].map(i => <View key={i} style={{ gap: 7 }}><Skeleton width="40%" height={11} color={c.surface2} /><Skeleton width="75%" height={9} color={c.surface2} /></View>)}</View>}
100
+ {!query.isPending && !workspaces.length && <View style={{ borderRadius: 10, ...raised(c), padding: compact ? 16 : 20, gap: 10 }}>
101
+ <Icon name="GitPullRequest" size={20} color={c.foregroundMuted} />
102
+ <Text style={[t.label, { fontSize: 14 }]}>No tracked workspaces yet</Text>
103
+ <Text style={t.meta}>Open a workspace, open Follow-up from its side panel or header, and choose Track this workspace. To track a whole project's workspaces automatically, turn the project on in settings.</Text>
104
+ <Button theme={theme} compact={compact} icon="Settings" title="Project settings" onPress={openSettings} />
105
+ </View>}
21
106
  {groups.map(group => {
22
- const rows = workspaces.filter(w => summary(w).status === group.status);
107
+ // Within Needs you, a workspace whose agent stopped for a question comes first.
108
+ const stopped = (w: Workspace) => Number(needsYou(w, now).some(i => i.kind === "question" && i.state === "stopped"));
109
+ const rows = workspaces.filter(w => overviewStatus(w, now) === group.status).sort((a, b) => stopped(b) - stopped(a) || Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
23
110
  if (!rows.length) return null;
24
- return <View key={group.status} style={{ marginTop: 24, gap: 9 }}><View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}><Text style={{ color: colorFor(theme, group.status), fontSize: 14, fontWeight: "600" }}>{group.title}</Text><Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{rows.length}</Text></View>{rows.map(w => <Pressable key={w.id} accessibilityRole="button" accessibilityLabel={`Open ${w.title}`} disabled={!navigation} onPress={() => navigation?.openWorkspace({ workspaceId: w.id })} style={({ pressed }) => ({ paddingVertical: 14, borderTopWidth: 1, borderColor: theme.colors.border, backgroundColor: pressed ? theme.colors.surface1 : "transparent", flexDirection: "row", alignItems: "flex-start", gap: 12 })}><Icon name={group.status === "done" ? "CircleCheck" : group.status === "needs-you" ? "CircleAlert" : "GitPullRequest"} size={17} color={colorFor(theme, group.status)} /><View style={{ flex: 1, gap: 6 }}><Text style={{ color: theme.colors.foreground, fontSize: 14, fontWeight: "600" }}>{w.title}{w.archived ? " · archived" : ""}</Text><Text style={{ color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 19 }}>{summary(w).reason}</Text><Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{new Set(w.prs.map(p => p.stack)).size} stacks · {w.prs.length} PRs · {w.obligations.filter(o => !["done", "waived"].includes(o.status)).length} remaining tasks</Text></View>{navigation && <Icon name="ArrowUpRight" size={14} color={theme.colors.foregroundMuted} />}</Pressable>)}</View>;
111
+ return <View key={group.status} style={{ gap: 8, marginTop: 4 }}>
112
+ <View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
113
+ <Text accessibilityRole="header" style={[t.label, { color: statusColor(c, group.status) }]}>{group.title}</Text>
114
+ <Text style={[t.small, { fontVariant: ["tabular-nums"] }]}>{rows.length}</Text>
115
+ </View>
116
+ <View style={{ borderRadius: 10, overflow: "hidden", backgroundColor: c.surface0, ...raised(c) }}>
117
+ {rows.map((w, i) => <WorkspaceRow key={w.id} w={w} theme={theme} compact={compact} now={now} first={i === 0} onOpen={navigation && !w.archived ? () => navigation.openWorkspace({ workspaceId: w.id }) : undefined} />)}
118
+ </View>
119
+ </View>;
25
120
  })}
26
- {query.data?.workspaces.some(w => w.archived) && <Button title={showArchived ? "Hide archived workspaces" : "Show archived history"} theme={theme} compact={layout.compact} onPress={() => setShowArchived(!showArchived)} />}
121
+ {archived > 0 && <Button theme={theme} compact={compact} kind="ghost" icon={showArchived ? "EyeOff" : "Archive"} title={showArchived ? "Hide archived workspaces" : `Show ${archived} archived`} onPress={() => setShowArchived(!showArchived)} />}
27
122
  </ScrollView>;
28
123
  }