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.
- package/OVERVIEW.md +19 -7
- package/README.md +70 -79
- package/THIRD_PARTY_NOTICES.md +33 -0
- package/assets/LICENSE.lucide +43 -0
- package/assets/icon.png +0 -0
- package/assets/icon.svg +1 -0
- package/client/button-visibility.ts +2 -1
- package/client/chat.tsx +163 -0
- package/client/display.tsx +12 -17
- package/client/header.tsx +92 -0
- package/client/keys.ts +3 -0
- package/client/kit.tsx +119 -0
- package/client/large.tsx +58 -0
- package/client/live.tsx +159 -0
- package/client/motion.tsx +93 -0
- package/client/needs-you.tsx +121 -0
- package/client/overview.tsx +111 -16
- package/client/panel.tsx +257 -68
- package/client/pr-controls.tsx +50 -33
- package/client/pr-sheet.tsx +63 -0
- package/client/questions.tsx +307 -0
- package/client/settings.tsx +55 -20
- package/client/spec.tsx +113 -0
- package/client/stacks.tsx +128 -0
- package/client/tasks.tsx +113 -22
- package/client/theme.ts +69 -0
- package/docs/screenshots/follow-up-chat.png +0 -0
- package/docs/screenshots/follow-up-hero.png +0 -0
- package/docs/screenshots/follow-up-large.png +0 -0
- package/docs/screenshots/follow-up-mobile.png +0 -0
- package/docs/screenshots/follow-up-overview.png +0 -0
- package/docs/screenshots/follow-up-pill.png +0 -0
- package/docs/screenshots/follow-up-pr.png +0 -0
- package/docs/screenshots/follow-up-questions.png +0 -0
- package/docs/screenshots/follow-up-states.png +0 -0
- package/index.client.tsx +47 -16
- package/index.server.ts +53 -0
- package/package.json +4 -2
- package/paseo-plugin.json +11 -3
- package/server/agent-hook.mjs +2 -0
- package/server/daemon.ts +37 -6
- package/server/discovery.ts +6 -3
- package/server/engine.ts +39 -0
- package/server/hook-source.ts +1 -1
- package/server/mcp-source.ts +1 -1
- package/server/mcp.mjs +12 -2
- package/server/questions.ts +112 -0
- package/server/spec.ts +102 -0
- package/shared/activity.ts +56 -0
- package/shared/attention.ts +212 -0
- package/shared/chat.ts +130 -0
- package/shared/contracts.ts +21 -0
- package/shared/display.ts +2 -1
- package/shared/errors.ts +5 -0
- package/shared/model.ts +10 -0
- package/shared/pr-actions.ts +8 -1
- package/shared/questions.ts +123 -0
- package/shared/spec.ts +46 -0
- package/skills/follow-through/SKILL.md +4 -0
- package/client/components.tsx +0 -58
- package/client/navigation.tsx +0 -23
- package/docs/screenshots/follow-up-settings.png +0 -0
- 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
|
+
}
|
package/client/overview.tsx
CHANGED
|
@@ -1,28 +1,123 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
-
import {
|
|
3
|
-
import { useRpc, type
|
|
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/
|
|
5
|
+
import { Icon } from "@getpaseo/plugin/client/ui";
|
|
6
6
|
import { listRpc } from "../shared/contracts";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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
|
-
|
|
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
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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={{
|
|
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
|
-
{
|
|
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
|
}
|