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
package/client/panel.tsx
CHANGED
|
@@ -1,82 +1,271 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ScrollView, Text, View } from "react-native";
|
|
3
|
+
import { Icon, useToast } from "@getpaseo/plugin/client/ui";
|
|
4
|
+
import { useRpc, useTabState, openExternalUrl, type PluginHostProps, type PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
|
4
5
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { getRpc, controlRpc, refreshRpc, activateRpc, browserStatusRpc, recoverRpc, sendRpc } from "../shared/contracts";
|
|
7
8
|
import { summary, type Workspace } from "../shared/model";
|
|
8
9
|
import { followUpObligations, recoveryBatch } from "../shared/follow-up";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
10
|
+
import { settledForDisplay } from "../shared/presentation";
|
|
11
|
+
import { ago, sendBlocker, shortRefs } from "../shared/attention";
|
|
12
|
+
import { Button, Card, CardRow, FoldRow, IconButton, errorText, text, useNow, type Theme } from "./kit";
|
|
13
|
+
import { Reveal, Skeleton, Spinner } from "./motion";
|
|
14
|
+
import { WorkspaceHeader } from "./header";
|
|
15
|
+
import { NeedsYouCard, overviewKey, workspaceKey } from "./needs-you";
|
|
16
|
+
import { StacksCard } from "./stacks";
|
|
17
|
+
import { TasksCard } from "./tasks";
|
|
18
|
+
import { PrSheet } from "./pr-sheet";
|
|
19
|
+
import { ActivityCard, QuestionSheet } from "./questions";
|
|
20
|
+
import { SpecRow, SpecSheet } from "./spec";
|
|
21
|
+
import { mergedColor, tint } from "./theme";
|
|
11
22
|
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
export
|
|
23
|
+
export { overviewKey, workspaceKey };
|
|
24
|
+
|
|
25
|
+
export type PanelLinks = { openSettings: () => void; openOverview: () => void; openLarge?: (workspaceId: string) => void };
|
|
26
|
+
type PanelProps = PluginWorkspacePanelProps & PanelLinks;
|
|
27
|
+
|
|
28
|
+
export function FollowPanel(props: PanelProps) {
|
|
15
29
|
const read = useRpc(getRpc);
|
|
16
30
|
const query = useQuery({ queryKey: workspaceKey(props.workspaceId), queryFn: () => read({ workspaceId: props.workspaceId }), refetchInterval: 5000 });
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
if (query.
|
|
31
|
+
const c = props.theme.colors;
|
|
32
|
+
const base = { flex: 1, backgroundColor: c.surface0 };
|
|
33
|
+
if (query.isPending) return <PanelSkeleton theme={props.theme} compact={props.layout.compact} />;
|
|
34
|
+
if (query.error && !query.data?.workspace) return <View style={[base, { padding: props.layout.compact ? 16 : 12 }]}>
|
|
35
|
+
<Card theme={props.theme} compact={props.layout.compact} icon="CloudOff" title="Can't reach Follow-up" style={{ marginHorizontal: 0, marginTop: 0 }}>
|
|
36
|
+
<CardRow theme={props.theme}>
|
|
37
|
+
<Text accessibilityRole="alert" style={[text(c).meta, { marginBottom: 10 }]}>The plugin didn't answer: {errorText(query.error)}. It may still be starting; your tracked work is saved on the host.</Text>
|
|
38
|
+
<Button title="Try again" icon="RefreshCw" theme={props.theme} compact={props.layout.compact} loading={query.isRefetching} onPress={() => void query.refetch()} />
|
|
39
|
+
</CardRow>
|
|
40
|
+
</Card>
|
|
41
|
+
</View>;
|
|
20
42
|
if (!query.data?.workspace) return <StartTracking {...props} loadError={query.data?.error} />;
|
|
21
|
-
return <
|
|
43
|
+
return <PanelWorkspace {...props} workspace={query.data.workspace} loadError={query.error ? "Refresh failed. Showing the last saved state." : query.data.error} />;
|
|
22
44
|
}
|
|
23
45
|
|
|
24
|
-
function
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
46
|
+
function PanelSkeleton({ theme, compact }: { theme: Theme; compact: boolean }) {
|
|
47
|
+
const c = theme.colors; const pad = compact ? 16 : 12;
|
|
48
|
+
return <View accessibilityLabel="Loading Follow-up" style={{ flex: 1, backgroundColor: c.surface0 }}>
|
|
49
|
+
<View style={{ padding: pad, gap: 10, borderBottomWidth: 1, borderBottomColor: c.border }}>
|
|
50
|
+
<Skeleton width="55%" height={14} color={c.surface2} />
|
|
51
|
+
<Skeleton width="85%" height={9} color={c.surface2} />
|
|
52
|
+
<Skeleton width="70%" height={9} color={c.surface2} />
|
|
53
|
+
<Skeleton width="100%" height={6} color={c.surface2} radius={3} />
|
|
54
|
+
</View>
|
|
55
|
+
{[0, 1].map(i => <View key={i} style={{ margin: pad, marginBottom: 0, padding: 12, gap: 10, borderRadius: 8, borderWidth: 1, borderColor: c.border }}>
|
|
56
|
+
<Skeleton width="35%" height={10} color={c.surface2} />
|
|
57
|
+
<Skeleton width="90%" height={9} color={c.surface2} />
|
|
58
|
+
<Skeleton width="75%" height={9} color={c.surface2} />
|
|
59
|
+
</View>)}
|
|
60
|
+
</View>;
|
|
28
61
|
}
|
|
29
62
|
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const browser = useQuery({ queryKey: ["follow-through", "browser"], queryFn: () => browserRead({}), refetchInterval: 5000 });
|
|
35
|
-
const invalidated = () => { void cache.invalidateQueries({ queryKey: workspaceKey(workspace.id) }); void cache.invalidateQueries({ queryKey: overviewKey }); };
|
|
36
|
-
const change = useMutation({ mutationFn: (input: Parameters<typeof control>[0]) => control(input), onSuccess: invalidated });
|
|
37
|
-
const update = useMutation({ mutationFn: () => refresh({ workspaceId: workspace.id }), onSuccess: invalidated });
|
|
38
|
-
const deliver = useMutation({ mutationFn: (batchId: string) => send({ workspaceId: workspace.id, batchId }), onSuccess: invalidated });
|
|
39
|
-
const recovery = useMutation({ mutationFn: () => runRecovery({ workspaceId: workspace.id }), onSuccess: invalidated });
|
|
40
|
-
const inspectAction = useMutation({ mutationFn: (actionId: string) => checkAction({ workspaceId: workspace.id, actionId }), onSuccess: invalidated });
|
|
41
|
-
const state = summary(workspace);
|
|
42
|
-
const outstanding = followUpObligations(workspace).filter(o => !["done", "waived"].includes(o.status));
|
|
43
|
-
const completed = followUpObligations(workspace).filter(o => ["done", "waived"].includes(o.status));
|
|
44
|
-
const batches = workspace.batches.filter(b => !recoveryBatch(b) && !["completed", "cancelled"].includes(b.state));
|
|
45
|
-
const lastRecovery = workspace.batches.filter(b => recoveryBatch(b) && b.state !== "cancelled").at(-1);
|
|
46
|
-
const latest = workspace.prs.map(p => p.snapshot?.observedAt).filter(Boolean).sort().at(-1);
|
|
47
|
-
const error = loadError ?? change.error?.message ?? update.error?.message ?? deliver.error?.message ?? null;
|
|
63
|
+
function StartTracking({ theme, workspaceId, layout, loadError, openSettings }: PanelProps & { loadError?: string | null }) {
|
|
64
|
+
const c = theme.colors; const t = text(c);
|
|
65
|
+
const activate = useRpc(activateRpc); const cache = useQueryClient(); const toast = useToast();
|
|
66
|
+
const start = useMutation({ mutationFn: () => activate({ workspaceId }), onSuccess: () => { toast.show("Tracking this workspace", { variant: "success" }); void cache.invalidateQueries({ queryKey: workspaceKey(workspaceId) }); void cache.invalidateQueries({ queryKey: overviewKey }); } });
|
|
48
67
|
const compact = layout.compact;
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
<View style={{
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
{
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
{lastRecovery.result && <Section title="Result" theme={theme} compact={compact} initiallyExpanded={false}><Text style={labelStyle}>{lastRecovery.result}</Text></Section>}
|
|
74
|
-
{lastRecovery.recipientAgentId && navigation && <Button title="Open recovery agent" theme={theme} compact={compact} onPress={() => navigation.openAgent({ agentId: lastRecovery.recipientAgentId! })} />}
|
|
75
|
-
{lastRecovery.state === "pending" && <Button title="Run queued recovery" theme={theme} compact={compact} disabled={deliver.isPending} onPress={() => deliver.mutate(lastRecovery.id)} />}
|
|
76
|
-
{["blocked", "uncertain"].includes(lastRecovery.state) && <Button title="Retry recovery" theme={theme} compact={compact} disabled={change.isPending} onPress={() => change.mutate({ workspaceId: workspace.id, retryBatchId: lastRecovery.id })} />}
|
|
77
|
-
</View>}
|
|
78
|
-
</Section>
|
|
79
|
-
{workspace.completion && <Text style={{ fontSize: 12, color: colorFor(theme, state.status), marginTop: 8 }}>Ready to archive.</Text>}
|
|
80
|
-
</Section>
|
|
68
|
+
// The empty state teaches with the panel's own rail: the three things Follow-up does, as three stack nodes.
|
|
69
|
+
const points: { icon: string; color: string; line: string }[] = [
|
|
70
|
+
{ icon: "GitMerge", color: mergedColor(c), line: "Follows every PR this workspace opens, stack by stack, with review and CI state." },
|
|
71
|
+
{ icon: "Wrench", color: c.statusDanger, line: "Gathers conflicts, failing checks and review threads into one repair batch for the main agent." },
|
|
72
|
+
{ icon: "Check", color: c.statusSuccess, line: "Tells you when the agent declares the work done and it's safe to archive." },
|
|
73
|
+
];
|
|
74
|
+
return <ScrollView style={{ flex: 1, backgroundColor: c.surface0 }} contentContainerStyle={{ padding: compact ? 16 : 12, paddingTop: compact ? 20 : 16, gap: 14 }}>
|
|
75
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
|
|
76
|
+
<Text accessibilityRole="header" style={[t.title, { flex: 1 }]}>Track this workspace</Text>
|
|
77
|
+
<View style={{ marginVertical: compact ? -12 : -4, marginRight: compact ? -10 : -4 }}><IconButton icon="Settings" label="Follow-up settings" onPress={openSettings} theme={theme} compact={compact} /></View>
|
|
78
|
+
</View>
|
|
79
|
+
<View>
|
|
80
|
+
{points.map((p, i) => <View key={p.icon} style={{ flexDirection: "row", gap: 10 }}>
|
|
81
|
+
<View style={{ width: 18, alignItems: "center" }}>
|
|
82
|
+
<View style={{ width: 18, height: 18, borderRadius: 9, alignItems: "center", justifyContent: "center", backgroundColor: tint(p.color, c) }}><Icon name={p.icon} size={11} color={p.color} /></View>
|
|
83
|
+
{i < points.length - 1 && <View style={{ width: 2, flex: 1, minHeight: 10, backgroundColor: c.border }} />}
|
|
84
|
+
</View>
|
|
85
|
+
<Text style={[t.meta, { flex: 1, color: c.foreground, paddingTop: 1, paddingBottom: i < points.length - 1 ? 14 : 0 }]}>{p.line}</Text>
|
|
86
|
+
</View>)}
|
|
87
|
+
</View>
|
|
88
|
+
<Text style={t.meta}>Nothing is sent, merged or archived without your click. Auto-send stays off until you turn it on for this workspace.</Text>
|
|
89
|
+
{(loadError || start.error) && <Text accessibilityRole="alert" style={[t.meta, { color: c.statusDanger }]}>{start.error ? errorText(start.error) : loadError}</Text>}
|
|
90
|
+
<Button title="Track this workspace" kind="primary" icon="Plus" theme={theme} compact={compact} loading={start.isPending} onPress={() => start.mutate()} />
|
|
91
|
+
<Text style={t.small}>To track every workspace in a project, turn it on in Settings. Tracking data stays on this host.</Text>
|
|
81
92
|
</ScrollView>;
|
|
82
93
|
}
|
|
94
|
+
|
|
95
|
+
/** The main agent's current repairs: what was sent and when. Pending batches live in Needs you. */
|
|
96
|
+
function AgentCard({ workspace: w, theme, compact, now, openAgent }: { workspace: Workspace; theme: Theme; compact: boolean; now: number; openAgent?: (id: string) => void }) {
|
|
97
|
+
const c = theme.colors; const t = text(c);
|
|
98
|
+
const sent = w.batches.find(b => !recoveryBatch(b) && ["sending", "sent"].includes(b.state));
|
|
99
|
+
const queued = w.enable_auto_sends ? w.batches.find(b => !recoveryBatch(b) && b.state === "pending") : undefined;
|
|
100
|
+
const batch = sent ?? queued;
|
|
101
|
+
if (!batch) return null;
|
|
102
|
+
const recipient = batch.recipientAgentId && batch.recipientAgentId !== w.mainAgentId ? batch.recipientAgentId : w.mainAgentId;
|
|
103
|
+
const blocker = queued && !sent ? sendBlocker(w, queued) : null;
|
|
104
|
+
return <Card theme={theme} compact={compact} icon="Bot" title={recipient === w.mainAgentId ? "Main agent is on it" : "Recovery agent is on it"}
|
|
105
|
+
right={openAgent && <Button theme={theme} compact={compact} kind="ghost" icon="ArrowUpRight" title="Open" accessibilityLabel="Open the agent's chat" onPress={() => openAgent(recipient)} />}>
|
|
106
|
+
<CardRow theme={theme}>
|
|
107
|
+
<Text style={[t.meta, { marginBottom: 6 }]}>{sent ? `Sent ${batch.tasks.length} repair${batch.tasks.length === 1 ? "" : "s"} ${ago(batch.sentAt, now)}. Follow-up waits for the agent to report back.` : `${batch.tasks.length} repair${batch.tasks.length === 1 ? "" : "s"} queued · ${blocker ?? "sends when the agent is idle"}`}</Text>
|
|
108
|
+
{batch.tasks.map((task, i) => <View key={task.key} style={{ flexDirection: "row", gap: 6 }}>
|
|
109
|
+
<Text style={[t.meta, { width: 16, fontVariant: ["tabular-nums"] }]}>{i + 1}.</Text>
|
|
110
|
+
<Text style={[t.meta, { flex: 1 }]}>{shortRefs(task.title, w)}</Text>
|
|
111
|
+
</View>)}
|
|
112
|
+
{!!batch.result && <Text style={[t.meta, { marginTop: 6, color: c.statusWarning }]}>{batch.result}</Text>}
|
|
113
|
+
</CardRow>
|
|
114
|
+
</Card>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The moment the loop closes: the agent declared Done and every requirement settled. */
|
|
118
|
+
function ShippedCard({ workspace: w, theme, compact, now }: { workspace: Workspace; theme: Theme; compact: boolean; now: number }) {
|
|
119
|
+
const c = theme.colors; const t = text(c);
|
|
120
|
+
if (summary(w, now).status !== "done" || !w.completion) return null;
|
|
121
|
+
const merged = w.prs.filter(p => p.snapshot?.state === "MERGED").length;
|
|
122
|
+
return <Reveal>
|
|
123
|
+
<View style={{ marginHorizontal: compact ? 16 : 12, marginTop: 12, borderRadius: 10, padding: 14, gap: 10, backgroundColor: tint(c.statusSuccess, c), borderWidth: 1, borderColor: tint(c.statusSuccess, c) }}>
|
|
124
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
125
|
+
<View style={{ width: 30, height: 30, borderRadius: 15, alignItems: "center", justifyContent: "center", backgroundColor: c.statusSuccess }}><Icon name="Check" size={17} color={c.surface0} /></View>
|
|
126
|
+
<View style={{ flex: 1 }}>
|
|
127
|
+
<Text accessibilityRole="header" style={[t.label, { fontSize: 14, color: c.foreground }]}>Shipped</Text>
|
|
128
|
+
<Text style={t.meta}>{merged ? `${merged} PR${merged === 1 ? "" : "s"} merged · ` : ""}declared {ago(w.completion.declaredAt, now)}</Text>
|
|
129
|
+
</View>
|
|
130
|
+
</View>
|
|
131
|
+
<Text selectable style={[t.body, { color: c.foreground }]}>{w.completion.summary}</Text>
|
|
132
|
+
<Text style={t.meta}>Archive the workspace from its menu when you're finished with it.</Text>
|
|
133
|
+
</View>
|
|
134
|
+
</Reveal>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Quiet extras: the main agent, optional recovery and the Graphite merge-when-ready connection. */
|
|
138
|
+
function WorkspaceCard({ workspace: w, theme, compact, open, toggle, openAgent, browserReady, openOverview, editSpec }: { workspace: Workspace; theme: Theme; compact: boolean; open: (key: string) => boolean; toggle: (key: string) => void; openAgent?: (id: string) => void; browserReady: boolean | null; openOverview: () => void; editSpec: () => void }) {
|
|
139
|
+
const c = theme.colors; const t = text(c);
|
|
140
|
+
const control = useRpc(controlRpc); const send = useRpc(sendRpc); const runRecovery = useRpc(recoverRpc); const cache = useQueryClient(); const toast = useToast();
|
|
141
|
+
const invalidate = () => { void cache.invalidateQueries({ queryKey: workspaceKey(w.id) }); void cache.invalidateQueries({ queryKey: overviewKey }); };
|
|
142
|
+
const fail = (e: Error) => toast.error(errorText(e));
|
|
143
|
+
const recovery = useMutation({ mutationFn: () => runRecovery({ workspaceId: w.id }), onSuccess: invalidate, onError: fail });
|
|
144
|
+
const runQueued = useMutation({ mutationFn: (batchId: string) => send({ workspaceId: w.id, batchId }), onSuccess: invalidate, onError: fail });
|
|
145
|
+
const retry = useMutation({ mutationFn: (batchId: string) => control({ workspaceId: w.id, retryBatchId: batchId }), onSuccess: invalidate, onError: fail });
|
|
146
|
+
const last = w.batches.filter(b => recoveryBatch(b) && b.state !== "cancelled").at(-1);
|
|
147
|
+
const recoveryState = last ? last.state === "sent" ? "Recovery is running" : last.state === "completed" ? "Last recovery finished" : last.state === "pending" ? "Recovery is ready to run" : last.state === "sending" ? "Starting recovery" : "Recovery needs a check" : null;
|
|
148
|
+
const active = w.prs.some(p => !settledForDisplay(p));
|
|
149
|
+
const shown = open("workspace");
|
|
150
|
+
const openUrl = (url: string) => { void openExternalUrl(url).catch(() => undefined); };
|
|
151
|
+
return <View style={{ marginHorizontal: compact ? 16 : 12, marginTop: 12, borderRadius: 8, borderWidth: 1, borderColor: c.border, overflow: "hidden" }}>
|
|
152
|
+
<View style={{ marginTop: -1 }}><FoldRow theme={theme} open={shown} onPress={() => toggle("workspace")} icon="SlidersHorizontal" iconColor={c.foregroundMuted} label="Spec, agent and recovery" /></View>
|
|
153
|
+
{shown && <>
|
|
154
|
+
<SpecRow workspace={w} theme={theme} compact={compact} onEdit={editSpec} />
|
|
155
|
+
<CardRow theme={theme}>
|
|
156
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
|
|
157
|
+
<View style={{ flex: 1, minWidth: 160, gap: 2 }}>
|
|
158
|
+
<Text style={t.label}>Main agent</Text>
|
|
159
|
+
<Text style={t.meta}>Owns the PRs, tasks and the Done call. Repairs go here.</Text>
|
|
160
|
+
</View>
|
|
161
|
+
{openAgent && <Button theme={theme} compact={compact} icon="MessageSquare" title="Open" accessibilityLabel="Open main agent" onPress={() => openAgent(w.mainAgentId)} />}
|
|
162
|
+
</View>
|
|
163
|
+
</CardRow>
|
|
164
|
+
<CardRow theme={theme}>
|
|
165
|
+
<View style={{ gap: 8 }}>
|
|
166
|
+
<View style={{ gap: 2 }}>
|
|
167
|
+
<Text style={t.label}>Recovery</Text>
|
|
168
|
+
<Text style={t.meta}>A separate agent reads this workspace's history to find PRs and tasks Follow-up missed. It never edits code or messages the main agent.</Text>
|
|
169
|
+
</View>
|
|
170
|
+
{recoveryState && <Text style={[t.meta, { color: c.foreground }]}>{recoveryState}{last?.settledAt ? ` · ${ago(last.settledAt)}` : ""}</Text>}
|
|
171
|
+
{!!last?.result && <Text numberOfLines={4} selectable style={t.meta}>{last.result}</Text>}
|
|
172
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
|
|
173
|
+
{last?.state === "pending" ? <Button title="Run queued recovery" theme={theme} compact={compact} loading={runQueued.isPending} onPress={() => runQueued.mutate(last.id)} />
|
|
174
|
+
: <Button title={recovery.isPending || last?.state === "sending" ? "Starting recovery…" : "Run recovery"} icon="History" theme={theme} compact={compact} loading={recovery.isPending}
|
|
175
|
+
disabled={recovery.isPending || w.archived || !!last && (["pending", "sending", "sent", "uncertain"].includes(last.state) || last.state === "blocked" && !last.settledAt)} onPress={() => recovery.mutate()} />}
|
|
176
|
+
{last?.recipientAgentId && openAgent && <Button title="Open recovery agent" kind="ghost" theme={theme} compact={compact} onPress={() => openAgent(last.recipientAgentId!)} />}
|
|
177
|
+
{last && ["blocked", "uncertain"].includes(last.state) && <Button title="Retry recovery" theme={theme} compact={compact} loading={retry.isPending} onPress={() => retry.mutate(last.id)} />}
|
|
178
|
+
</View>
|
|
179
|
+
</View>
|
|
180
|
+
</CardRow>
|
|
181
|
+
{active && browserReady === false && <CardRow theme={theme}>
|
|
182
|
+
<View style={{ gap: 8 }}>
|
|
183
|
+
<View style={{ gap: 2 }}>
|
|
184
|
+
<Text style={t.label}>Merge when ready</Text>
|
|
185
|
+
<Text style={t.meta}>Optional. With Send to Paseo paired and Graphite open in Chrome, each open PR gets a When ready switch. Chrome asks before each change unless you allowed it for the repository.</Text>
|
|
186
|
+
</View>
|
|
187
|
+
<Button title="Open Graphite" icon="ExternalLink" theme={theme} compact={compact} accessibilityLabel="Connect Graphite" onPress={() => openUrl("https://app.graphite.com")} />
|
|
188
|
+
</View>
|
|
189
|
+
</CardRow>}
|
|
190
|
+
<CardRow theme={theme}>
|
|
191
|
+
<Button title="All tracked workspaces" kind="ghost" icon="LayoutList" theme={theme} compact={compact} onPress={openOverview} />
|
|
192
|
+
</CardRow>
|
|
193
|
+
</>}
|
|
194
|
+
</View>;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const folds = z.object({ open: z.array(z.string()) });
|
|
198
|
+
function historyLabel(w: Workspace): string {
|
|
199
|
+
const stacks = new Set(w.prs.map(p => p.stack)).size;
|
|
200
|
+
const tasks = followUpObligations(w).filter(o => ["done", "waived"].includes(o.status)).length;
|
|
201
|
+
return [`${w.prs.length} PR${w.prs.length === 1 ? "" : "s"} in ${stacks} stack${stacks === 1 ? "" : "s"}`, tasks ? `${tasks} task${tasks === 1 ? "" : "s"} done` : null].filter(Boolean).join(" · ");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export type Folds = { isOpen: (key: string) => boolean; toggle: (key: string) => void };
|
|
205
|
+
/** Fold choices shared by a workspace's panel and its larger view during the app session. */
|
|
206
|
+
const sessionFolds = new Map<string, string[]>();
|
|
207
|
+
export function useSessionFolds(workspaceId: string): Folds {
|
|
208
|
+
const [open, setOpen] = useState<string[]>(() => sessionFolds.get(workspaceId) ?? []);
|
|
209
|
+
return { isOpen: key => open.includes(key), toggle: key => { const next = open.includes(key) ? open.filter(k => k !== key) : [...open, key]; sessionFolds.set(workspaceId, next); setOpen(next); } };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The panel keeps its folds in the tab's persisted state and mirrors them for the larger view. */
|
|
213
|
+
function PanelWorkspace(props: PanelProps & { workspace: Workspace; loadError?: string | null }) {
|
|
214
|
+
const [state, setState] = useTabState(props.tab, folds, { open: sessionFolds.get(props.workspaceId) ?? [] });
|
|
215
|
+
const isOpen = (key: string) => state.open.includes(key);
|
|
216
|
+
const toggle = (key: string) => { const next = isOpen(key) ? state.open.filter(k => k !== key) : [...state.open, key]; sessionFolds.set(props.workspaceId, next); setState({ open: next }); };
|
|
217
|
+
const { openLarge } = props;
|
|
218
|
+
return <WorkspaceView {...props} folds={{ isOpen, toggle }} resize={openLarge ? { icon: "Maximize2", label: "Open the larger view", onPress: () => openLarge(props.workspaceId) } : null} />;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Two columns from this content width: actions on the left, the stacks on the right. */
|
|
222
|
+
export const WIDE_MIN = 820;
|
|
223
|
+
|
|
224
|
+
export function WorkspaceView({ theme, layout, workspace: w, navigation, loadError, openSettings, openOverview, folds: { isOpen, toggle }, resize, large = false }: PluginHostProps & {
|
|
225
|
+
navigation?: PluginWorkspacePanelProps["navigation"]; workspace: Workspace; loadError?: string | null; openSettings: () => void; openOverview: () => void;
|
|
226
|
+
folds: Folds; resize: { icon: "Maximize2" | "Minimize2"; label: string; onPress: () => void } | null; large?: boolean;
|
|
227
|
+
}) {
|
|
228
|
+
const control = useRpc(controlRpc); const refresh = useRpc(refreshRpc); const browserRead = useRpc(browserStatusRpc);
|
|
229
|
+
const cache = useQueryClient(); const toast = useToast();
|
|
230
|
+
const now = useNow(30_000);
|
|
231
|
+
const compact = layout.compact;
|
|
232
|
+
const [width, setWidth] = useState(0);
|
|
233
|
+
const wide = !compact && width >= WIDE_MIN;
|
|
234
|
+
const [sheet, setSheet] = useState<{ key: string; intent: "merge" | "reviewers" | null } | null>(null);
|
|
235
|
+
const [question, setQuestion] = useState<string | null>(null);
|
|
236
|
+
const [specOpen, setSpecOpen] = useState(false);
|
|
237
|
+
const browser = useQuery({ queryKey: ["follow-through", "browser"], queryFn: () => browserRead({}), refetchInterval: 5000 });
|
|
238
|
+
const invalidated = () => { void cache.invalidateQueries({ queryKey: workspaceKey(w.id) }); void cache.invalidateQueries({ queryKey: overviewKey }); };
|
|
239
|
+
const auto = useMutation({ mutationFn: (next: boolean) => control({ workspaceId: w.id, enable_auto_sends: next }), onSuccess: (_, next) => { toast.show(next ? "Auto-send on for this workspace" : "Auto-send off · repairs wait for your click"); invalidated(); }, onError: e => toast.error(errorText(e)) });
|
|
240
|
+
const update = useMutation({ mutationFn: () => refresh({ workspaceId: w.id }), onSuccess: () => setTimeout(invalidated, 1200), onError: e => toast.error(errorText(e)) });
|
|
241
|
+
const openAgent = navigation ? (agentId: string) => navigation.openAgent({ agentId }) : undefined;
|
|
242
|
+
const done = summary(w, now).status === "done";
|
|
243
|
+
const openPr = (key: string) => setSheet({ key, intent: null });
|
|
244
|
+
// The expand button only appears while the panel is narrow; a wide panel or the larger view keeps its collapse button.
|
|
245
|
+
const resizeShown = compact ? null : resize?.icon === "Maximize2" && wide ? null : resize;
|
|
246
|
+
const header = <WorkspaceHeader workspace={w} theme={theme} compact={compact} now={now} onSettings={openSettings} onRefresh={() => update.mutate()} refreshing={update.isPending} onAuto={next => auto.mutate(next)} autoPending={auto.isPending} notice={loadError ?? null} onOpenPr={openPr} resize={resizeShown} hideTitle={large} />;
|
|
247
|
+
const shipped = <ShippedCard workspace={w} theme={theme} compact={compact} now={now} />;
|
|
248
|
+
const needs = <NeedsYouCard workspace={w} theme={theme} compact={compact} now={now} actions={{ openAgent, openPr: (key, intent) => setSheet({ key, intent }), openQuestion: setQuestion }} />;
|
|
249
|
+
const agent = <AgentCard workspace={w} theme={theme} compact={compact} now={now} openAgent={openAgent} />;
|
|
250
|
+
// Once shipped, the stack and tasks are history: one quiet fold instead of two near-empty cards.
|
|
251
|
+
const history = done && <View style={{ marginHorizontal: compact ? 16 : 12, marginTop: 12, borderRadius: 8, borderWidth: 1, borderColor: theme.colors.border, overflow: "hidden" }}>
|
|
252
|
+
<View style={{ marginTop: -1 }}><FoldRow theme={theme} open={isOpen("history")} onPress={() => toggle("history")} icon="History" iconColor={theme.colors.foregroundMuted} label={historyLabel(w)} /></View>
|
|
253
|
+
</View>;
|
|
254
|
+
const showWork = !done || isOpen("history");
|
|
255
|
+
const stacks = showWork && <StacksCard workspace={w} theme={theme} compact={compact} open={isOpen} toggle={toggle} onOpen={openPr} wide={wide} />;
|
|
256
|
+
const tasks = showWork && <TasksCard workspace={w} theme={theme} compact={compact} open={isOpen} toggle={toggle} onOpenQuestion={setQuestion} />;
|
|
257
|
+
const log = <ActivityCard workspace={w} theme={theme} compact={compact} now={now} open={isOpen} toggle={toggle} onOpenQuestion={setQuestion} />;
|
|
258
|
+
const extras = <WorkspaceCard workspace={w} theme={theme} compact={compact} open={isOpen} toggle={toggle} openAgent={openAgent} browserReady={browser.data ? browser.data.ready : null} openOverview={openOverview} editSpec={() => setSpecOpen(true)} />;
|
|
259
|
+
return <View onLayout={event => setWidth(event.nativeEvent.layout.width)} style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
|
260
|
+
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 20, ...(large ? { maxWidth: 1240, width: "100%", alignSelf: "center", paddingHorizontal: 12 } : null) }}>
|
|
261
|
+
{header}
|
|
262
|
+
{wide ? <View style={{ flexDirection: "row", alignItems: "flex-start" }}>
|
|
263
|
+
<View style={{ flex: 5, minWidth: 0 }}>{shipped}{needs}{agent}{history}{tasks}</View>
|
|
264
|
+
<View style={{ flex: 6, minWidth: 0 }}>{stacks}{log}{extras}</View>
|
|
265
|
+
</View> : <>{shipped}{needs}{agent}{history}{stacks}{tasks}{log}{extras}</>}
|
|
266
|
+
</ScrollView>
|
|
267
|
+
<PrSheet workspace={w} prKey={sheet?.key ?? null} intent={sheet?.intent ?? null} theme={theme} compact={compact} browserReady={browser.data?.ready ?? false} onClose={() => setSheet(null)} />
|
|
268
|
+
<QuestionSheet workspace={w} questionId={question} theme={theme} compact={compact} openAgent={openAgent} onClose={() => setQuestion(null)} />
|
|
269
|
+
<SpecSheet workspace={w} open={specOpen} theme={theme} compact={compact} onClose={() => setSpecOpen(false)} />
|
|
270
|
+
</View>;
|
|
271
|
+
}
|
package/client/pr-controls.tsx
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
2
|
-
import {
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Text, View } from "react-native";
|
|
3
3
|
import { useRpc } from "@getpaseo/plugin/client";
|
|
4
|
+
import { Icon, useToast } from "@getpaseo/plugin/client/ui";
|
|
4
5
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
6
|
import { confirmPrActionRpc, preparePrActionRpc, prControlStatusRpc, requestReviewersRpc } from "../shared/contracts";
|
|
6
|
-
import { prActionBlocks, type ActionPlan, type PrActionKind } from "../shared/pr-actions";
|
|
7
|
+
import { mergeBlockers, prActionBlocks, type ActionPlan, type PrActionKind } from "../shared/pr-actions";
|
|
8
|
+
import { mergeQueued, mergeReady } from "../shared/attention";
|
|
7
9
|
import type { Pr, Workspace } from "../shared/model";
|
|
8
|
-
import { Button, type Theme } from "./
|
|
10
|
+
import { Button, Switch, errorText, text, type Theme } from "./kit";
|
|
9
11
|
import { defaultReviewers, noReviewers } from "../shared/reviewers";
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Merge, merge-when-ready and reviewer requests for one PR. Every write needs a click; Merge always
|
|
15
|
+
* prepares a fresh plan and asks again before anything reaches Graphite.
|
|
16
|
+
*/
|
|
17
|
+
export function PrControls({ pr, workspace, theme, compact, browserReady, intent }: { browserReady: boolean; pr: Pr; workspace: Workspace; theme: Theme; compact: boolean; intent?: "merge" | "reviewers" | null }) {
|
|
12
18
|
const read = useRpc(prControlStatusRpc); const prepare = useRpc(preparePrActionRpc); const confirm = useRpc(confirmPrActionRpc); const cache = useQueryClient();
|
|
13
19
|
const requestReviewers = useRpc(requestReviewersRpc);
|
|
20
|
+
const toast = useToast();
|
|
21
|
+
const c = theme.colors; const t = text(c);
|
|
14
22
|
const [plan, setPlan] = useState<ActionPlan | null>(null); const [notice, setNotice] = useState<string | null>(null);
|
|
15
23
|
const statusKey = ["follow-through", "pr-controls", workspace.id, pr.key, pr.snapshot?.head];
|
|
16
24
|
const status = useQuery({ queryKey: statusKey, queryFn: () => read({ workspaceId: workspace.id, prKey: pr.key }), refetchInterval: 5000, enabled: browserReady });
|
|
17
25
|
const invalidate = () => { void cache.invalidateQueries({ queryKey: ["follow-through", "workspace", workspace.id] }); void cache.invalidateQueries({ queryKey: ["follow-through", "pr-controls", workspace.id] }); void cache.invalidateQueries({ queryKey: ["follow-through", "overview"] }); };
|
|
18
|
-
const reviewRequest = useMutation({ mutationFn: () => requestReviewers({ workspaceId: workspace.id, prKey: pr.key }), onSuccess: reply => { setNotice(reply.result); invalidate(); }, onError: invalidate });
|
|
19
|
-
const submit = useMutation({ mutationFn: (input: { planId: string; scope: "target" | "downstack" }) => confirm(input), onSuccess: reply => { setPlan(null); setNotice(reply.result); invalidate(); }, onError: invalidate });
|
|
26
|
+
const reviewRequest = useMutation({ mutationFn: () => requestReviewers({ workspaceId: workspace.id, prKey: pr.key }), onSuccess: reply => { setNotice(reply.result); if (reply.state === "submitted") toast.show(reply.result, { variant: "success" }); invalidate(); }, onError: invalidate });
|
|
27
|
+
const submit = useMutation({ mutationFn: (input: { planId: string; scope: "target" | "downstack" }) => confirm(input), onSuccess: reply => { setPlan(null); setNotice(reply.result); if (reply.state === "submitted") toast.show(reply.result, { variant: "success" }); invalidate(); }, onError: invalidate });
|
|
20
28
|
const checking = useMutation({ mutationFn: (kind: PrActionKind) => prepare({ workspaceId: workspace.id, prKey: pr.key, kind }), onSuccess: result => {
|
|
21
29
|
if (result.kind !== "merge" && result.prs.length === 1 && !result.blockers.length && !result.prs[0].blockers.length) submit.mutate({ planId: result.id, scope: "target" });
|
|
22
30
|
else setPlan(result);
|
|
@@ -26,41 +34,50 @@ export function PrControls({ pr, workspace, theme, compact, browserReady }: { br
|
|
|
26
34
|
const actual = browserReady ? status.data?.pr : null;
|
|
27
35
|
const known = !!actual && !status.error && !status.data?.error;
|
|
28
36
|
const enabled = known && actual.mergeWhenReady;
|
|
29
|
-
const queued = actual?.merging ||
|
|
37
|
+
const queued = actual?.merging || mergeQueued(pr);
|
|
30
38
|
const recentlySubmitted = workspace.prActions.some(a => a.kind === "merge" && a.state === "submitted" && a.heads[pr.key] === pr.snapshot?.head && Date.now() - Date.parse(a.createdAt) < 30_000);
|
|
31
|
-
const canMerge =
|
|
39
|
+
const canMerge = mergeReady(pr, workspace) && !actual?.merging;
|
|
32
40
|
const disabled = busy || reviewRequest.isPending || pendingAction || workspace.archived;
|
|
33
41
|
const missingReviewers = noReviewers(pr);
|
|
34
42
|
const reviewers = defaultReviewers(pr.repo).filter(r => r.login.toLowerCase() !== pr.snapshot?.reviewers?.author?.toLowerCase());
|
|
35
43
|
const begin = (kind: PrActionKind) => { setPlan(null); setNotice(null); submit.reset(); checking.reset(); checking.mutate(kind); };
|
|
36
|
-
const
|
|
37
|
-
const error =
|
|
44
|
+
const failure = checking.error ?? submit.error ?? reviewRequest.error;
|
|
45
|
+
const error = failure ? errorText(failure) : (browserReady ? status.data?.error : null) ?? null;
|
|
38
46
|
const scopeBlocked = (scope: "target" | "downstack") => !!plan && (plan.blockers.length > 0 || (scope === "target" ? plan.prs.slice(-1) : plan.prs).some(p => p.blockers.length > 0));
|
|
39
47
|
const act = (scope: "target" | "downstack") => { if (plan) submit.mutate({ planId: plan.id, scope }); };
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
// Opening the sheet from "Merge…" starts the read-only check; the merge itself still needs the confirm click.
|
|
49
|
+
const started = useRef(false);
|
|
50
|
+
useEffect(() => { if (intent === "merge" && canMerge && !disabled && !started.current) { started.current = true; begin("merge"); } }, [intent, canMerge]);
|
|
51
|
+
if (pr.snapshot?.state !== "OPEN") return null;
|
|
52
|
+
const waitingFor = mergeBlockers(pr).filter(b => !b.startsWith("Graphite "));
|
|
53
|
+
return <View style={{ gap: 10 }}>
|
|
54
|
+
{missingReviewers && <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
|
|
55
|
+
<Icon name="UserX" size={14} color={c.statusWarning} />
|
|
56
|
+
<Text style={[t.meta, { flex: 1, minWidth: 120, color: c.statusWarning }]}>No reviewers yet</Text>
|
|
57
|
+
{!pr.snapshot?.draft && reviewers.length > 0 && <Button title={reviewRequest.isPending ? "Requesting…" : "Add reviewers"} accessibilityLabel={`Add reviewers: ${reviewers.map(r => r.name).join(" and ")} for ${pr.repo} pull request ${pr.number}`} icon="UserPlus" theme={theme} compact={compact} disabled={disabled} loading={reviewRequest.isPending} onPress={() => { setNotice(null); reviewRequest.mutate(); }} />}
|
|
45
58
|
</View>}
|
|
46
|
-
<View style={{ flexDirection: "row", alignItems: "center",
|
|
47
|
-
{
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
</
|
|
53
|
-
{
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
{!plan && <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 12 }}>
|
|
60
|
+
{(canMerge || queued || recentlySubmitted || busy) ? <Button title={busy ? checking.isPending ? "Checking…" : "Submitting…" : queued ? "Queued to merge" : recentlySubmitted ? "Submitted" : `Merge #${pr.number}`} icon={queued ? "Clock" : "GitMerge"} theme={theme} compact={compact} kind={canMerge ? "primary" : "secondary"} loading={busy} disabled={disabled || !canMerge} onPress={() => begin("merge")} />
|
|
61
|
+
: <Text style={[t.meta, { flex: 1 }]}>{waitingFor.length ? `Merge opens when it clears: ${waitingFor.join(", ").toLowerCase()}.` : "Waiting for Graphite to report it ready to merge."}</Text>}
|
|
62
|
+
{browserReady && <Switch theme={theme} compact={compact} label={`When ready${known ? "" : " · unknown"}`} accessibilityLabel={`Merge when ready for ${pr.repo} pull request ${pr.number}`} value={known ? !!enabled : null} disabled={disabled} onChange={() => begin(enabled ? "disable_mwr" : "enable_mwr")} />}
|
|
63
|
+
</View>}
|
|
64
|
+
{error && <Text accessibilityRole="alert" style={[t.meta, { color: c.statusDanger }]}>{error}</Text>}
|
|
65
|
+
{notice && <Text accessibilityLiveRegion="polite" style={t.meta}>{notice}</Text>}
|
|
66
|
+
{plan && (plan.kind === "merge" || browserReady) && <View accessibilityRole="summary" style={{ gap: 8 }}>
|
|
67
|
+
<Text accessibilityRole="header" style={[t.label, { fontSize: 14, lineHeight: 20 }]}>{plan.kind === "merge" ? plan.prs.length > 1 ? "Merge the PRs underneath too?" : "Merge this PR?" : `${plan.kind === "enable_mwr" ? "Enable" : "Disable"} for downstack too?`}</Text>
|
|
68
|
+
<Text style={t.meta}>{plan.kind === "merge" ? plan.prs.length > 1 ? `#${pr.number} depends on ${plan.prs.length - 1} unmerged PR${plan.prs.length === 2 ? "" : "s"}. Merge these ${plan.prs.length} PRs in order:` : "Graphite will merge using this repository’s settings and required protections." : `Change merge when ready for #${pr.number} only, or for all ${plan.prs.length} listed PRs.`}</Text>
|
|
69
|
+
{plan.prs.map((p, i) => <View key={p.key} style={{ flexDirection: "row", gap: 8 }}>
|
|
70
|
+
<Text style={[t.meta, { width: 16, textAlign: "right", fontVariant: ["tabular-nums"] }]}>{i + 1}.</Text>
|
|
71
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
72
|
+
<Text style={[t.body, { fontSize: 12, lineHeight: 17 }]}>#{p.number} · {p.title}</Text>
|
|
73
|
+
{p.blockers.length > 0 && <Text style={[t.meta, { color: c.statusWarning }]}>{p.blockers.join(" · ")}</Text>}
|
|
74
|
+
</View>
|
|
75
|
+
</View>)}
|
|
76
|
+
{plan.blockers.map(blocker => <Text key={blocker} style={[t.meta, { color: c.statusWarning }]}>{blocker}</Text>)}
|
|
77
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 2 }}>
|
|
78
|
+
<Button title={plan.kind === "merge" ? plan.prs.length > 1 ? `Merge all ${plan.prs.length} PRs` : `Merge #${pr.number}` : `${plan.kind === "enable_mwr" ? "Enable" : "Disable"} for all ${plan.prs.length} PRs`} kind="primary" theme={theme} compact={compact} loading={submit.isPending} disabled={submit.isPending || scopeBlocked("downstack")} onPress={() => act("downstack")} />
|
|
62
79
|
{plan.kind !== "merge" && plan.prs.length > 1 && <Button title="Just this PR" theme={theme} compact={compact} disabled={submit.isPending || scopeBlocked("target")} onPress={() => act("target")} />}
|
|
63
|
-
<Button title="Cancel" theme={theme} compact={compact} disabled={submit.isPending} onPress={() => { setPlan(null); checking.reset(); submit.reset(); }} />
|
|
80
|
+
<Button title="Cancel" kind="ghost" theme={theme} compact={compact} disabled={submit.isPending} onPress={() => { setPlan(null); checking.reset(); submit.reset(); }} />
|
|
64
81
|
</View>
|
|
65
82
|
</View>}
|
|
66
83
|
</View>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Text, View } from "react-native";
|
|
2
|
+
import { openExternalUrl } from "@getpaseo/plugin/client";
|
|
3
|
+
import { Icon, Modal } from "@getpaseo/plugin/client/ui";
|
|
4
|
+
import type { Workspace } from "../shared/model";
|
|
5
|
+
import { compactPrStatus, graphitePrUrl } from "../shared/presentation";
|
|
6
|
+
import { ago, prTone, stackOrder } from "../shared/attention";
|
|
7
|
+
import { Button, Chip, text, type Theme } from "./kit";
|
|
8
|
+
import { PrControls } from "./pr-controls";
|
|
9
|
+
import { toneColor } from "./theme";
|
|
10
|
+
|
|
11
|
+
function Fact({ icon, color, children, theme }: { icon: string; color?: string; children: React.ReactNode; theme: Theme }) {
|
|
12
|
+
const c = theme.colors;
|
|
13
|
+
return <View style={{ flexDirection: "row", gap: 9 }}>
|
|
14
|
+
<View style={{ paddingTop: 2 }}><Icon name={icon} size={14} color={color ?? c.foregroundMuted} /></View>
|
|
15
|
+
<Text selectable style={[text(c).meta, { flex: 1, color: color ?? c.foregroundMuted }]}>{children}</Text>
|
|
16
|
+
</View>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Everything about one PR, with its merge and reviewer controls. A sheet on phones, a dialog on desktop. */
|
|
20
|
+
export function PrSheet({ workspace: w, prKey, intent, theme, compact, browserReady, onClose }: { workspace: Workspace; prKey: string | null; intent: "merge" | "reviewers" | null; theme: Theme; compact: boolean; browserReady: boolean; onClose: () => void }) {
|
|
21
|
+
const c = theme.colors; const t = text(c);
|
|
22
|
+
const pr = w.prs.find(p => p.key === prKey) ?? null;
|
|
23
|
+
const s = pr?.snapshot;
|
|
24
|
+
const open = (url: string) => { void openExternalUrl(url).catch(() => undefined); };
|
|
25
|
+
const status = pr ? compactPrStatus(pr) : null;
|
|
26
|
+
const tone = pr ? prTone(pr) : "unknown";
|
|
27
|
+
const stack = pr ? stackOrder(w.prs.filter(p => p.stack === pr.stack)) : [];
|
|
28
|
+
const parent = pr?.parent ? w.prs.find(p => p.key === pr.parent) : null;
|
|
29
|
+
const reviewers = s?.reviewers?.complete ? [...new Set([...s.reviewers.requested, ...s.reviewers.teams.map(team => `team/${team}`), ...s.reviewers.reviewed])] : [];
|
|
30
|
+
return <Modal title={pr ? `Pull request #${pr.number}` : "Pull request"} icon={<Icon name={tone === "merged" ? "GitMerge" : "GitPullRequest"} size={18} color={toneColor(c, tone)} />} open={!!pr} onOpenChange={next => { if (!next) onClose(); }}>
|
|
31
|
+
<Modal.Content contentContainerStyle={{ padding: compact ? 16 : 20, gap: 14 }}>
|
|
32
|
+
{pr && <>
|
|
33
|
+
<View style={{ gap: 6 }}>
|
|
34
|
+
<Text selectable style={[t.title, { fontSize: 16, lineHeight: 22 }]}>{s?.title ?? `${pr.repo} pull request`}</Text>
|
|
35
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
|
|
36
|
+
{status && <Chip theme={theme} label={status.label} color={toneColor(c, tone === "pending" || tone === "draft" ? "unknown" : tone)} />}
|
|
37
|
+
<Text style={t.meta}>{pr.repo} · {pr.stack}{stack.length > 1 ? ` · ${stack.indexOf(pr) + 1} of ${stack.length}` : ""}</Text>
|
|
38
|
+
</View>
|
|
39
|
+
</View>
|
|
40
|
+
<View style={{ gap: 8 }}>
|
|
41
|
+
{pr.error && <Fact theme={theme} icon="TriangleAlert" color={c.statusWarning}>{pr.error}</Fact>}
|
|
42
|
+
{parent && <Fact theme={theme} icon="CornerLeftDown">Stacked on #{parent.number} · {parent.snapshot?.title ?? parent.stack}</Fact>}
|
|
43
|
+
{s?.conflict && <Fact theme={theme} icon="TriangleAlert" color={c.statusDanger}>Conflicts with its base branch</Fact>}
|
|
44
|
+
{!!s?.failedChecks.length && <Fact theme={theme} icon="CircleX" color={c.statusDanger}>Required checks failed: {s.failedChecks.join(", ")}</Fact>}
|
|
45
|
+
{!!s?.otherFailedChecks?.length && <Fact theme={theme} icon="CircleX" color={c.statusWarning}>Optional checks failed (not required to merge): {s.otherFailedChecks.join(", ")}</Fact>}
|
|
46
|
+
{s?.state === "OPEN" && s.checksPending && <Fact theme={theme} icon="LoaderCircle">Required checks are still running</Fact>}
|
|
47
|
+
{!!s?.threads.length && <Fact theme={theme} icon="MessageSquare" color={c.statusWarning}>{s.threads.length} unresolved review thread{s.threads.length === 1 ? "" : "s"}</Fact>}
|
|
48
|
+
{s?.reviewDecision === "CHANGES_REQUESTED" && <Fact theme={theme} icon="MessageSquareWarning" color={c.statusWarning}>Changes requested</Fact>}
|
|
49
|
+
{reviewers.length > 0 && <Fact theme={theme} icon="Users">Reviewers: {reviewers.join(", ")}</Fact>}
|
|
50
|
+
{s?.deployment?.environment && <Fact theme={theme} icon="Rocket">{s.deployment.evidence}</Fact>}
|
|
51
|
+
{s?.observedAt && <Fact theme={theme} icon="Clock">Checked {ago(s.observedAt)}</Fact>}
|
|
52
|
+
</View>
|
|
53
|
+
{s?.state === "OPEN" && <View style={{ paddingTop: 12, borderTopWidth: 1, borderTopColor: c.border }}>
|
|
54
|
+
<PrControls key={`${pr.key}:${intent ?? ""}`} pr={pr} workspace={w} theme={theme} compact={compact} browserReady={browserReady} intent={intent} />
|
|
55
|
+
</View>}
|
|
56
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, paddingTop: 12, borderTopWidth: 1, borderTopColor: c.border }}>
|
|
57
|
+
<Button theme={theme} compact={compact} icon="ExternalLink" title="Open in Graphite" accessibilityLabel={`Open ${pr.repo} pull request ${pr.number} in Graphite`} onPress={() => open(graphitePrUrl(pr))} />
|
|
58
|
+
{s?.url && <Button theme={theme} compact={compact} kind="ghost" icon="Github" title="GitHub" accessibilityLabel={`Open ${pr.repo} pull request ${pr.number} on GitHub`} onPress={() => open(s.url)} />}
|
|
59
|
+
</View>
|
|
60
|
+
</>}
|
|
61
|
+
</Modal.Content>
|
|
62
|
+
</Modal>;
|
|
63
|
+
}
|