@omercnet/paseo-pr-radar 0.3.4-next.72.1
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/LICENSE +21 -0
- package/README.md +64 -0
- package/client/pr-radar.tsx +860 -0
- package/client/radar.ts +601 -0
- package/docs/images/pr-radar-compact.png +0 -0
- package/docs/images/pr-radar-github-inbox-compact.png +0 -0
- package/docs/images/pr-radar-github-inbox-wide.png +0 -0
- package/docs/images/pr-radar-wide.png +0 -0
- package/index.client.tsx +23 -0
- package/index.server.ts +9 -0
- package/package.json +66 -0
- package/paseo-plugin.json +4 -0
- package/server/viewer-scope.ts +402 -0
- package/shared/viewer-scope.ts +53 -0
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
import { type PluginSurfaceProps, usePaseo, useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
ActivityIndicator,
|
|
6
|
+
FlatList,
|
|
7
|
+
Linking,
|
|
8
|
+
Pressable,
|
|
9
|
+
Text,
|
|
10
|
+
TextInput,
|
|
11
|
+
View,
|
|
12
|
+
} from "react-native";
|
|
13
|
+
import { acknowledgeViewerScope, viewerScope } from "../shared/viewer-scope";
|
|
14
|
+
import {
|
|
15
|
+
type AgentEntry,
|
|
16
|
+
agentActionFor,
|
|
17
|
+
applyViewerScope,
|
|
18
|
+
BUCKET_TITLES,
|
|
19
|
+
BUCKETS,
|
|
20
|
+
buildAgentPrompt,
|
|
21
|
+
buildRadarSnapshot,
|
|
22
|
+
checkSummary,
|
|
23
|
+
formatAge,
|
|
24
|
+
hasActiveAgent,
|
|
25
|
+
matchesRow,
|
|
26
|
+
mergeInboxRows,
|
|
27
|
+
type PaseoApi,
|
|
28
|
+
type PaseoWorkspace,
|
|
29
|
+
type RadarBucket,
|
|
30
|
+
type RadarRow,
|
|
31
|
+
} from "./radar";
|
|
32
|
+
|
|
33
|
+
const PAGE_LIMIT = 200;
|
|
34
|
+
const MAX_PAGES = 10;
|
|
35
|
+
const BACKSTOP_REFRESH_MS = 60_000;
|
|
36
|
+
const EVENT_DEBOUNCE_MS = 500;
|
|
37
|
+
const CLOCK_TICK_MS = 30_000;
|
|
38
|
+
|
|
39
|
+
type SavedView = "security" | "updated" | "stale" | "automation";
|
|
40
|
+
|
|
41
|
+
const SAVED_VIEW_TITLES: Record<SavedView, string> = {
|
|
42
|
+
security: "Security",
|
|
43
|
+
updated: "Updated",
|
|
44
|
+
stale: "Stale",
|
|
45
|
+
automation: "Automation",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const STALE_AFTER_MS = 3 * 24 * 60 * 60 * 1_000;
|
|
49
|
+
|
|
50
|
+
async function loadAgents(paseo: PaseoApi): Promise<AgentEntry[]> {
|
|
51
|
+
const entries: AgentEntry[] = [];
|
|
52
|
+
let cursor: string | undefined;
|
|
53
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
54
|
+
const result = await paseo.agents.list({
|
|
55
|
+
sort: [{ key: "updated_at", direction: "desc" }],
|
|
56
|
+
page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
|
|
57
|
+
});
|
|
58
|
+
entries.push(...result.entries);
|
|
59
|
+
cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined;
|
|
60
|
+
if (!cursor) break;
|
|
61
|
+
}
|
|
62
|
+
return entries;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function loadWorkspaces(paseo: PaseoApi): Promise<PaseoWorkspace[]> {
|
|
66
|
+
const workspaces: PaseoWorkspace[] = [];
|
|
67
|
+
let cursor: string | undefined;
|
|
68
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
69
|
+
const result = await paseo.workspaces.list({
|
|
70
|
+
page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
|
|
71
|
+
});
|
|
72
|
+
workspaces.push(...result.entries);
|
|
73
|
+
cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined;
|
|
74
|
+
if (!cursor) break;
|
|
75
|
+
}
|
|
76
|
+
return workspaces;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function bucketColor(bucket: RadarBucket, colors: PluginSurfaceProps["theme"]["colors"]): string {
|
|
80
|
+
if (bucket === "needs-you") return colors.statusDanger;
|
|
81
|
+
if (bucket === "ready") return colors.statusSuccess;
|
|
82
|
+
if (bucket === "being-handled") return colors.accent;
|
|
83
|
+
return colors.statusWarning;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function agentState(row: RadarRow): string {
|
|
87
|
+
const agent = row.agents[0];
|
|
88
|
+
if (!agent) return "No active agent";
|
|
89
|
+
const extra = row.agents.length > 1 ? ` +${row.agents.length - 1}` : "";
|
|
90
|
+
if (agent.pendingPermissions > 0) return `${agent.title} · permission${extra}`;
|
|
91
|
+
if (agent.requiresAttention && agent.attentionReason !== "finished") {
|
|
92
|
+
return `${agent.title} · needs input${extra}`;
|
|
93
|
+
}
|
|
94
|
+
return `${agent.title} · ${agent.status}${extra}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function PrRadar({ theme, layout, host, navigation }: PluginSurfaceProps) {
|
|
98
|
+
const paseo = usePaseo();
|
|
99
|
+
const queryClient = useQueryClient();
|
|
100
|
+
const queryKey = useMemo(() => ["pr-radar", host.id], [host.id]);
|
|
101
|
+
const resolveViewerScope = useRpc(viewerScope);
|
|
102
|
+
const acknowledgeUpdates = useRpc(acknowledgeViewerScope);
|
|
103
|
+
const [selected, setSelected] = useState<RadarBucket | null>(null);
|
|
104
|
+
const [activeOnly, setActiveOnly] = useState(false);
|
|
105
|
+
const [savedView, setSavedView] = useState<SavedView | null>(null);
|
|
106
|
+
const [windowDays, setWindowDays] = useState(30);
|
|
107
|
+
const [search, setSearch] = useState("");
|
|
108
|
+
const [now, setNow] = useState(() => Date.now());
|
|
109
|
+
const [openError, setOpenError] = useState<string | null>(null);
|
|
110
|
+
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
|
111
|
+
|
|
112
|
+
const {
|
|
113
|
+
data,
|
|
114
|
+
error,
|
|
115
|
+
isPending,
|
|
116
|
+
isFetching: isDirectoryFetching,
|
|
117
|
+
refetch,
|
|
118
|
+
} = useQuery({
|
|
119
|
+
queryKey,
|
|
120
|
+
queryFn: async () => {
|
|
121
|
+
const [workspaces, agents] = await Promise.all([loadWorkspaces(paseo), loadAgents(paseo)]);
|
|
122
|
+
return buildRadarSnapshot(workspaces, agents);
|
|
123
|
+
},
|
|
124
|
+
refetchInterval: BACKSTOP_REFRESH_MS,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
const clock = setInterval(() => setNow(Date.now()), CLOCK_TICK_MS);
|
|
129
|
+
return () => clearInterval(clock);
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
134
|
+
const invalidate = () => {
|
|
135
|
+
if (timer) return;
|
|
136
|
+
timer = setTimeout(() => {
|
|
137
|
+
timer = undefined;
|
|
138
|
+
void queryClient.invalidateQueries({ queryKey });
|
|
139
|
+
}, EVENT_DEBOUNCE_MS);
|
|
140
|
+
};
|
|
141
|
+
const unsubscribeAgents = paseo.agents.subscribe(invalidate);
|
|
142
|
+
const unsubscribeWorkspaces = paseo.workspaces.subscribe(invalidate);
|
|
143
|
+
return () => {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
unsubscribeAgents();
|
|
146
|
+
unsubscribeWorkspaces();
|
|
147
|
+
};
|
|
148
|
+
}, [paseo, queryClient, queryKey]);
|
|
149
|
+
|
|
150
|
+
const rawRows = data?.rows ?? [];
|
|
151
|
+
const scopeUrls = useMemo(() => rawRows.map((row) => row.url), [rawRows]);
|
|
152
|
+
const {
|
|
153
|
+
data: viewerData,
|
|
154
|
+
error: viewerQueryError,
|
|
155
|
+
isFetching: isViewerFetching,
|
|
156
|
+
refetch: refetchViewer,
|
|
157
|
+
} = useQuery({
|
|
158
|
+
queryKey: ["pr-radar-viewer-scope", host.id, scopeUrls, windowDays],
|
|
159
|
+
queryFn: () => resolveViewerScope({ urls: scopeUrls, windowDays }),
|
|
160
|
+
staleTime: 5 * 60_000,
|
|
161
|
+
refetchInterval: 5 * 60_000,
|
|
162
|
+
});
|
|
163
|
+
const mergedRows = useMemo(
|
|
164
|
+
() => (data ? mergeInboxRows(data, viewerData?.inboxItems ?? []) : []),
|
|
165
|
+
[data, viewerData],
|
|
166
|
+
);
|
|
167
|
+
const rows = useMemo(
|
|
168
|
+
() => applyViewerScope(mergedRows, viewerData ?? null),
|
|
169
|
+
[mergedRows, viewerData],
|
|
170
|
+
);
|
|
171
|
+
const isFetching = isDirectoryFetching || isViewerFetching;
|
|
172
|
+
const viewerError =
|
|
173
|
+
viewerData?.error ??
|
|
174
|
+
(viewerQueryError instanceof Error
|
|
175
|
+
? viewerQueryError.message
|
|
176
|
+
: viewerQueryError
|
|
177
|
+
? "error"
|
|
178
|
+
: null);
|
|
179
|
+
const acknowledgeMutation = useMutation({
|
|
180
|
+
mutationFn: () => acknowledgeUpdates({ windowDays }),
|
|
181
|
+
onSuccess: async () => {
|
|
182
|
+
setActionNotice("PR Radar updates marked as seen.");
|
|
183
|
+
await refetchViewer();
|
|
184
|
+
},
|
|
185
|
+
onError: (mutationError) => {
|
|
186
|
+
setOpenError(
|
|
187
|
+
mutationError instanceof Error ? mutationError.message : "Could not clear radar updates.",
|
|
188
|
+
);
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
const agentMutation = useMutation({
|
|
192
|
+
mutationFn: async (row: RadarRow) => {
|
|
193
|
+
const action = agentActionFor(row);
|
|
194
|
+
if (!action) throw new Error("No agent action is available for this pull request.");
|
|
195
|
+
const prompt = buildAgentPrompt(row);
|
|
196
|
+
if (action.kind === "ask") {
|
|
197
|
+
await paseo.agents.ref(action.agentId).send(prompt);
|
|
198
|
+
return `Asked an agent to handle ${row.repository}#${row.number ?? "PR"}.`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const { config } = await paseo.config.get();
|
|
202
|
+
const profiles = config.agentProfiles ?? [];
|
|
203
|
+
const profile =
|
|
204
|
+
profiles.find((candidate) => candidate.name.toLowerCase() === "model router") ??
|
|
205
|
+
profiles[0];
|
|
206
|
+
if (!profile) {
|
|
207
|
+
throw new Error("Configure an agent profile in Paseo before starting an agent.");
|
|
208
|
+
}
|
|
209
|
+
const provider = profile.model ? `${profile.provider}/${profile.model}` : profile.provider;
|
|
210
|
+
const agentOptions = {
|
|
211
|
+
config: {
|
|
212
|
+
provider,
|
|
213
|
+
...(profile.modeId ? { modeId: profile.modeId } : {}),
|
|
214
|
+
...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}),
|
|
215
|
+
...(profile.featureValues ? { featureValues: profile.featureValues } : {}),
|
|
216
|
+
},
|
|
217
|
+
title: `PR Radar: ${row.repository}#${row.number ?? "PR"}`,
|
|
218
|
+
prompt,
|
|
219
|
+
};
|
|
220
|
+
const targetWorkspace =
|
|
221
|
+
action.kind === "checkout"
|
|
222
|
+
? await paseo.workspaces.create({
|
|
223
|
+
title: `${row.reviewRequestedFromMe ? "Review" : "Work on"} ${row.repository}#${action.number}`,
|
|
224
|
+
source: {
|
|
225
|
+
kind: "worktree",
|
|
226
|
+
cwd: action.cwd,
|
|
227
|
+
action: "checkout",
|
|
228
|
+
checkoutSource: {
|
|
229
|
+
kind: "change_request",
|
|
230
|
+
forge: "github",
|
|
231
|
+
number: action.number,
|
|
232
|
+
projectPath: action.repository,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
})
|
|
236
|
+
: paseo.workspaces.ref(action.workspaceId);
|
|
237
|
+
const created = await targetWorkspace.agents.create(agentOptions);
|
|
238
|
+
navigation?.openAgent({ agentId: created.id });
|
|
239
|
+
return `Started ${profile.name} for ${row.repository}#${row.number ?? "PR"}.`;
|
|
240
|
+
},
|
|
241
|
+
onMutate: () => {
|
|
242
|
+
setOpenError(null);
|
|
243
|
+
setActionNotice(null);
|
|
244
|
+
},
|
|
245
|
+
onSuccess: (message) => {
|
|
246
|
+
setActionNotice(message);
|
|
247
|
+
},
|
|
248
|
+
onError: (mutationError) => {
|
|
249
|
+
setOpenError(
|
|
250
|
+
mutationError instanceof Error ? mutationError.message : "Could not contact an agent.",
|
|
251
|
+
);
|
|
252
|
+
},
|
|
253
|
+
onSettled: () => queryClient.invalidateQueries({ queryKey }),
|
|
254
|
+
});
|
|
255
|
+
const counts = useMemo(() => {
|
|
256
|
+
const result: Record<RadarBucket, number> = {
|
|
257
|
+
"needs-you": 0,
|
|
258
|
+
ready: 0,
|
|
259
|
+
"being-handled": 0,
|
|
260
|
+
waiting: 0,
|
|
261
|
+
};
|
|
262
|
+
for (const row of rows) result[row.bucket] += 1;
|
|
263
|
+
return result;
|
|
264
|
+
}, [rows]);
|
|
265
|
+
const activeCount = useMemo(
|
|
266
|
+
() => rows.filter((row) => hasActiveAgent(row.agents)).length,
|
|
267
|
+
[rows],
|
|
268
|
+
);
|
|
269
|
+
const savedViewCounts = useMemo<Record<SavedView, number>>(
|
|
270
|
+
() => ({
|
|
271
|
+
security: rows.filter(({ isSecurity }) => isSecurity).length,
|
|
272
|
+
updated: rows.filter(({ changes }) => changes.length > 0).length,
|
|
273
|
+
stale: rows.filter(
|
|
274
|
+
({ activityAt }) => activityAt && now - Date.parse(activityAt) >= STALE_AFTER_MS,
|
|
275
|
+
).length,
|
|
276
|
+
automation: rows.filter(({ authorKind }) => authorKind === "bot").length,
|
|
277
|
+
}),
|
|
278
|
+
[now, rows],
|
|
279
|
+
);
|
|
280
|
+
const visibleRows = useMemo(
|
|
281
|
+
() =>
|
|
282
|
+
rows.filter((row) => {
|
|
283
|
+
const matchesSavedView =
|
|
284
|
+
!savedView ||
|
|
285
|
+
(savedView === "security" && row.isSecurity) ||
|
|
286
|
+
(savedView === "updated" && row.changes.length > 0) ||
|
|
287
|
+
(savedView === "stale" &&
|
|
288
|
+
Boolean(row.activityAt && now - Date.parse(row.activityAt) >= STALE_AFTER_MS)) ||
|
|
289
|
+
(savedView === "automation" && row.authorKind === "bot");
|
|
290
|
+
return (
|
|
291
|
+
(!selected || row.bucket === selected) &&
|
|
292
|
+
(!activeOnly || hasActiveAgent(row.agents)) &&
|
|
293
|
+
matchesSavedView &&
|
|
294
|
+
matchesRow(row, search)
|
|
295
|
+
);
|
|
296
|
+
}),
|
|
297
|
+
[activeOnly, now, rows, savedView, search, selected],
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const styles = useMemo(() => {
|
|
301
|
+
const gutter = layout.compact ? 14 : 24;
|
|
302
|
+
const mutedBorder = `${theme.colors.foregroundMuted}35`;
|
|
303
|
+
return {
|
|
304
|
+
screen: { flex: 1, backgroundColor: theme.colors.surface0 },
|
|
305
|
+
content: {
|
|
306
|
+
width: "100%" as const,
|
|
307
|
+
maxWidth: 1180,
|
|
308
|
+
alignSelf: "center" as const,
|
|
309
|
+
paddingBottom: 48,
|
|
310
|
+
},
|
|
311
|
+
header: {
|
|
312
|
+
paddingHorizontal: gutter,
|
|
313
|
+
paddingTop: layout.compact ? 18 : 28,
|
|
314
|
+
paddingBottom: 18,
|
|
315
|
+
gap: 14,
|
|
316
|
+
},
|
|
317
|
+
signalLine: {
|
|
318
|
+
flexDirection: "row" as const,
|
|
319
|
+
alignItems: "center" as const,
|
|
320
|
+
justifyContent: "space-between" as const,
|
|
321
|
+
gap: 12,
|
|
322
|
+
},
|
|
323
|
+
signalLabel: { flexDirection: "row" as const, alignItems: "center" as const, gap: 8 },
|
|
324
|
+
signalDot: {
|
|
325
|
+
width: 7,
|
|
326
|
+
height: 7,
|
|
327
|
+
borderRadius: 4,
|
|
328
|
+
backgroundColor: theme.colors.statusSuccess,
|
|
329
|
+
},
|
|
330
|
+
eyebrow: {
|
|
331
|
+
color: theme.colors.foregroundMuted,
|
|
332
|
+
fontSize: 11,
|
|
333
|
+
fontWeight: "700" as const,
|
|
334
|
+
letterSpacing: 1.8,
|
|
335
|
+
},
|
|
336
|
+
heroTitle: {
|
|
337
|
+
color: theme.colors.foreground,
|
|
338
|
+
fontSize: layout.compact ? 28 : 38,
|
|
339
|
+
lineHeight: layout.compact ? 32 : 42,
|
|
340
|
+
fontWeight: "800" as const,
|
|
341
|
+
letterSpacing: -1.2,
|
|
342
|
+
},
|
|
343
|
+
heroDetail: { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 18 },
|
|
344
|
+
summary: {
|
|
345
|
+
flexDirection: layout.compact ? ("column" as const) : ("row" as const),
|
|
346
|
+
gap: 1,
|
|
347
|
+
borderWidth: 1,
|
|
348
|
+
borderColor: theme.colors.border,
|
|
349
|
+
backgroundColor: theme.colors.border,
|
|
350
|
+
borderRadius: 12,
|
|
351
|
+
overflow: "hidden" as const,
|
|
352
|
+
},
|
|
353
|
+
metric: {
|
|
354
|
+
flex: 1,
|
|
355
|
+
paddingHorizontal: layout.compact ? 13 : 16,
|
|
356
|
+
paddingVertical: layout.compact ? 10 : 14,
|
|
357
|
+
backgroundColor: theme.colors.surface1,
|
|
358
|
+
flexDirection: layout.compact ? ("row" as const) : ("column" as const),
|
|
359
|
+
alignItems: layout.compact ? ("center" as const) : ("flex-start" as const),
|
|
360
|
+
justifyContent: "space-between" as const,
|
|
361
|
+
gap: 3,
|
|
362
|
+
},
|
|
363
|
+
metricValue: {
|
|
364
|
+
color: theme.colors.foreground,
|
|
365
|
+
fontSize: layout.compact ? 22 : 30,
|
|
366
|
+
fontWeight: "800" as const,
|
|
367
|
+
},
|
|
368
|
+
metricLabel: {
|
|
369
|
+
color: theme.colors.foregroundMuted,
|
|
370
|
+
fontSize: 10,
|
|
371
|
+
fontWeight: "700" as const,
|
|
372
|
+
letterSpacing: 0.7,
|
|
373
|
+
textTransform: "uppercase" as const,
|
|
374
|
+
},
|
|
375
|
+
refresh: {
|
|
376
|
+
minHeight: 36,
|
|
377
|
+
justifyContent: "center" as const,
|
|
378
|
+
paddingHorizontal: 12,
|
|
379
|
+
borderWidth: 1,
|
|
380
|
+
borderColor: theme.colors.accent,
|
|
381
|
+
borderRadius: 8,
|
|
382
|
+
backgroundColor: theme.colors.accent,
|
|
383
|
+
},
|
|
384
|
+
refreshPressed: { opacity: 0.72 },
|
|
385
|
+
refreshText: {
|
|
386
|
+
color: theme.colors.accentForeground,
|
|
387
|
+
fontSize: 13,
|
|
388
|
+
fontWeight: "700" as const,
|
|
389
|
+
},
|
|
390
|
+
chips: { flexDirection: "row" as const, flexWrap: "wrap" as const, gap: 7 },
|
|
391
|
+
chip: {
|
|
392
|
+
minHeight: 32,
|
|
393
|
+
justifyContent: "center" as const,
|
|
394
|
+
paddingHorizontal: 10,
|
|
395
|
+
borderWidth: 1,
|
|
396
|
+
borderColor: mutedBorder,
|
|
397
|
+
borderRadius: 16,
|
|
398
|
+
},
|
|
399
|
+
chipActive: {
|
|
400
|
+
backgroundColor: theme.colors.foreground,
|
|
401
|
+
borderColor: theme.colors.foreground,
|
|
402
|
+
},
|
|
403
|
+
chipText: { color: theme.colors.foregroundMuted, fontSize: 12, fontWeight: "600" as const },
|
|
404
|
+
chipTextActive: { color: theme.colors.surface0 },
|
|
405
|
+
search: {
|
|
406
|
+
minHeight: 40,
|
|
407
|
+
color: theme.colors.foreground,
|
|
408
|
+
backgroundColor: theme.colors.surface1,
|
|
409
|
+
borderWidth: 1,
|
|
410
|
+
borderColor: theme.colors.border,
|
|
411
|
+
borderRadius: 8,
|
|
412
|
+
paddingHorizontal: 12,
|
|
413
|
+
paddingVertical: 8,
|
|
414
|
+
fontSize: 14,
|
|
415
|
+
},
|
|
416
|
+
warning: {
|
|
417
|
+
marginHorizontal: gutter,
|
|
418
|
+
marginBottom: 10,
|
|
419
|
+
paddingHorizontal: 12,
|
|
420
|
+
paddingVertical: 10,
|
|
421
|
+
borderLeftWidth: 3,
|
|
422
|
+
borderLeftColor: theme.colors.statusWarning,
|
|
423
|
+
backgroundColor: theme.colors.surface1,
|
|
424
|
+
},
|
|
425
|
+
warningText: { color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 17 },
|
|
426
|
+
row: {
|
|
427
|
+
flexDirection: "row" as const,
|
|
428
|
+
marginHorizontal: gutter,
|
|
429
|
+
borderTopWidth: 1,
|
|
430
|
+
borderTopColor: theme.colors.border,
|
|
431
|
+
minHeight: layout.compact ? 152 : 122,
|
|
432
|
+
},
|
|
433
|
+
rail: { width: 3, marginVertical: 14, borderRadius: 2 },
|
|
434
|
+
rowBody: {
|
|
435
|
+
flex: 1,
|
|
436
|
+
paddingVertical: 14,
|
|
437
|
+
paddingLeft: 12,
|
|
438
|
+
gap: 6,
|
|
439
|
+
},
|
|
440
|
+
rowTop: { flexDirection: "row" as const, alignItems: "center" as const, gap: 8 },
|
|
441
|
+
identifier: {
|
|
442
|
+
color: theme.colors.foregroundMuted,
|
|
443
|
+
fontSize: 11,
|
|
444
|
+
fontWeight: "700" as const,
|
|
445
|
+
letterSpacing: 0.4,
|
|
446
|
+
textTransform: "uppercase" as const,
|
|
447
|
+
},
|
|
448
|
+
badge: {
|
|
449
|
+
color: theme.colors.foregroundMuted,
|
|
450
|
+
fontSize: 10,
|
|
451
|
+
fontWeight: "700" as const,
|
|
452
|
+
borderWidth: 1,
|
|
453
|
+
borderColor: mutedBorder,
|
|
454
|
+
borderRadius: 4,
|
|
455
|
+
paddingHorizontal: 5,
|
|
456
|
+
paddingVertical: 2,
|
|
457
|
+
},
|
|
458
|
+
title: {
|
|
459
|
+
color: theme.colors.foreground,
|
|
460
|
+
fontSize: layout.compact ? 15 : 16,
|
|
461
|
+
fontWeight: "700" as const,
|
|
462
|
+
lineHeight: 21,
|
|
463
|
+
},
|
|
464
|
+
reasonLine: { flexDirection: "row" as const, alignItems: "center" as const, gap: 7 },
|
|
465
|
+
reasonDot: { width: 7, height: 7, borderRadius: 4 },
|
|
466
|
+
reason: { color: theme.colors.foreground, fontSize: 13, fontWeight: "600" as const },
|
|
467
|
+
metadata: { color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 17 },
|
|
468
|
+
actions: {
|
|
469
|
+
flexDirection: "row" as const,
|
|
470
|
+
alignItems: "center" as const,
|
|
471
|
+
gap: 8,
|
|
472
|
+
paddingLeft: layout.compact ? 0 : 12,
|
|
473
|
+
paddingTop: layout.compact ? 4 : 0,
|
|
474
|
+
},
|
|
475
|
+
action: {
|
|
476
|
+
minHeight: 34,
|
|
477
|
+
justifyContent: "center" as const,
|
|
478
|
+
paddingHorizontal: 10,
|
|
479
|
+
borderWidth: 1,
|
|
480
|
+
borderColor: theme.colors.border,
|
|
481
|
+
borderRadius: 7,
|
|
482
|
+
backgroundColor: theme.colors.surface1,
|
|
483
|
+
},
|
|
484
|
+
actionPrimary: { borderColor: theme.colors.accent },
|
|
485
|
+
actionAgent: {
|
|
486
|
+
borderColor: theme.colors.accent,
|
|
487
|
+
backgroundColor: theme.colors.accent,
|
|
488
|
+
},
|
|
489
|
+
actionAgentText: { color: theme.colors.accentForeground },
|
|
490
|
+
actionDisabled: { opacity: 0.55 },
|
|
491
|
+
actionText: { color: theme.colors.foreground, fontSize: 12, fontWeight: "600" as const },
|
|
492
|
+
actionPrimaryText: { color: theme.colors.accent },
|
|
493
|
+
empty: {
|
|
494
|
+
paddingHorizontal: gutter,
|
|
495
|
+
paddingVertical: 52,
|
|
496
|
+
alignItems: "center" as const,
|
|
497
|
+
gap: 8,
|
|
498
|
+
},
|
|
499
|
+
emptyTitle: { color: theme.colors.foreground, fontSize: 18, fontWeight: "700" as const },
|
|
500
|
+
emptyDetail: {
|
|
501
|
+
color: theme.colors.foregroundMuted,
|
|
502
|
+
fontSize: 13,
|
|
503
|
+
lineHeight: 19,
|
|
504
|
+
textAlign: "center" as const,
|
|
505
|
+
maxWidth: 420,
|
|
506
|
+
},
|
|
507
|
+
error: { color: theme.colors.statusDanger, fontSize: 13, lineHeight: 18 },
|
|
508
|
+
notice: { color: theme.colors.statusSuccess, fontSize: 13, lineHeight: 18 },
|
|
509
|
+
spinner: { marginVertical: 52 },
|
|
510
|
+
};
|
|
511
|
+
}, [layout.compact, theme]);
|
|
512
|
+
|
|
513
|
+
const openPr = useCallback(async (row: RadarRow) => {
|
|
514
|
+
setOpenError(null);
|
|
515
|
+
try {
|
|
516
|
+
await Linking.openURL(row.url);
|
|
517
|
+
} catch {
|
|
518
|
+
setOpenError(`Could not open ${row.repository}#${row.number ?? "PR"}.`);
|
|
519
|
+
}
|
|
520
|
+
}, []);
|
|
521
|
+
|
|
522
|
+
const renderRow = ({ item }: { item: RadarRow }) => {
|
|
523
|
+
const color = bucketColor(item.bucket, theme.colors);
|
|
524
|
+
const age = formatAge(item.activityAt, now);
|
|
525
|
+
const isStale = Boolean(item.activityAt && now - Date.parse(item.activityAt) >= STALE_AFTER_MS);
|
|
526
|
+
const branchSummary =
|
|
527
|
+
item.headRefName && item.baseRefName ? ` · ${item.headRefName} → ${item.baseRefName}` : "";
|
|
528
|
+
const primaryAgent = item.agents[0];
|
|
529
|
+
const agentAction = agentActionFor(item);
|
|
530
|
+
const actionPending = agentMutation.isPending && agentMutation.variables?.id === item.id;
|
|
531
|
+
const ownershipLabel = item.reviewRequestedFromMe
|
|
532
|
+
? "REVIEW"
|
|
533
|
+
: item.ownership === "mine"
|
|
534
|
+
? "YOURS"
|
|
535
|
+
: item.ownership === "external"
|
|
536
|
+
? "EXTERNAL"
|
|
537
|
+
: "SCOPE UNKNOWN";
|
|
538
|
+
const actions = (
|
|
539
|
+
<View style={styles.actions}>
|
|
540
|
+
{agentAction ? (
|
|
541
|
+
<Pressable
|
|
542
|
+
accessibilityRole="button"
|
|
543
|
+
accessibilityLabel={`${agentAction.kind === "ask" ? "Ask an agent to handle" : "Start an agent for"} ${item.repository} ${item.number ?? ""}`}
|
|
544
|
+
accessibilityState={{ busy: actionPending, disabled: agentMutation.isPending }}
|
|
545
|
+
disabled={agentMutation.isPending}
|
|
546
|
+
onPress={() => agentMutation.mutate(item)}
|
|
547
|
+
style={({ pressed }) => [
|
|
548
|
+
styles.action,
|
|
549
|
+
styles.actionAgent,
|
|
550
|
+
(pressed || actionPending) && styles.refreshPressed,
|
|
551
|
+
agentMutation.isPending && styles.actionDisabled,
|
|
552
|
+
]}
|
|
553
|
+
>
|
|
554
|
+
<Text style={[styles.actionText, styles.actionAgentText]}>
|
|
555
|
+
{actionPending
|
|
556
|
+
? agentAction.kind === "ask"
|
|
557
|
+
? "Sending…"
|
|
558
|
+
: "Starting…"
|
|
559
|
+
: agentAction.kind === "ask"
|
|
560
|
+
? "Ask agent"
|
|
561
|
+
: "Start agent"}
|
|
562
|
+
</Text>
|
|
563
|
+
</Pressable>
|
|
564
|
+
) : null}
|
|
565
|
+
{navigation && primaryAgent ? (
|
|
566
|
+
<Pressable
|
|
567
|
+
accessibilityRole="button"
|
|
568
|
+
accessibilityLabel={`Open agent ${primaryAgent.title}`}
|
|
569
|
+
onPress={() => navigation.openAgent({ agentId: primaryAgent.id })}
|
|
570
|
+
style={({ pressed }) => [styles.action, pressed && styles.refreshPressed]}
|
|
571
|
+
>
|
|
572
|
+
<Text style={styles.actionText}>Open agent</Text>
|
|
573
|
+
</Pressable>
|
|
574
|
+
) : null}
|
|
575
|
+
{navigation && !primaryAgent && item.workspaceIds[0] ? (
|
|
576
|
+
<Pressable
|
|
577
|
+
accessibilityRole="button"
|
|
578
|
+
accessibilityLabel={`Open workspace for ${item.repository} ${item.number ?? ""}`}
|
|
579
|
+
onPress={() => navigation.openWorkspace({ workspaceId: item.workspaceIds[0] })}
|
|
580
|
+
style={({ pressed }) => [styles.action, pressed && styles.refreshPressed]}
|
|
581
|
+
>
|
|
582
|
+
<Text style={styles.actionText}>Open workspace</Text>
|
|
583
|
+
</Pressable>
|
|
584
|
+
) : null}
|
|
585
|
+
<Pressable
|
|
586
|
+
accessibilityRole="link"
|
|
587
|
+
accessibilityLabel={`Open pull request ${item.repository} ${item.number ?? ""}`}
|
|
588
|
+
onPress={() => void openPr(item)}
|
|
589
|
+
style={({ pressed }) => [
|
|
590
|
+
styles.action,
|
|
591
|
+
styles.actionPrimary,
|
|
592
|
+
pressed && styles.refreshPressed,
|
|
593
|
+
]}
|
|
594
|
+
>
|
|
595
|
+
<Text style={[styles.actionText, styles.actionPrimaryText]}>Open PR</Text>
|
|
596
|
+
</Pressable>
|
|
597
|
+
</View>
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
return (
|
|
601
|
+
<View style={styles.row}>
|
|
602
|
+
<View style={[styles.rail, { backgroundColor: color }]} />
|
|
603
|
+
<View style={styles.rowBody}>
|
|
604
|
+
<View style={styles.rowTop}>
|
|
605
|
+
<Text style={styles.identifier} numberOfLines={1}>
|
|
606
|
+
{item.repository}
|
|
607
|
+
{item.number ? ` #${item.number}` : ""}
|
|
608
|
+
</Text>
|
|
609
|
+
<Text style={styles.badge}>{ownershipLabel}</Text>
|
|
610
|
+
{item.isDraft ? <Text style={styles.badge}>DRAFT</Text> : null}
|
|
611
|
+
{item.authorKind === "bot" ? <Text style={styles.badge}>BOT</Text> : null}
|
|
612
|
+
{item.isSecurity ? <Text style={styles.badge}>SECURITY</Text> : null}
|
|
613
|
+
{isStale ? <Text style={styles.badge}>STALE</Text> : null}
|
|
614
|
+
</View>
|
|
615
|
+
<Text style={styles.title} numberOfLines={2} ellipsizeMode="tail">
|
|
616
|
+
{item.title}
|
|
617
|
+
</Text>
|
|
618
|
+
<View style={styles.reasonLine}>
|
|
619
|
+
<View style={[styles.reasonDot, { backgroundColor: color }]} />
|
|
620
|
+
<Text style={styles.reason}>{item.reason}</Text>
|
|
621
|
+
</View>
|
|
622
|
+
<Text style={styles.metadata} numberOfLines={1} ellipsizeMode="middle">
|
|
623
|
+
{checkSummary(item)}
|
|
624
|
+
{branchSummary}
|
|
625
|
+
</Text>
|
|
626
|
+
<Text style={styles.metadata} numberOfLines={1} ellipsizeMode="tail">
|
|
627
|
+
{item.author ? `${item.author} · ` : ""}
|
|
628
|
+
{agentState(item)}
|
|
629
|
+
{age ? ` · activity ${age} ago` : ""}
|
|
630
|
+
{item.comments > 0 ? ` · ${item.comments} comments` : ""}
|
|
631
|
+
</Text>
|
|
632
|
+
{item.changes.length > 0 ? (
|
|
633
|
+
<Text style={styles.notice} numberOfLines={2}>
|
|
634
|
+
Updated · {item.changes.join(" · ")}
|
|
635
|
+
</Text>
|
|
636
|
+
) : null}
|
|
637
|
+
{layout.compact ? actions : null}
|
|
638
|
+
</View>
|
|
639
|
+
{layout.compact ? null : actions}
|
|
640
|
+
</View>
|
|
641
|
+
);
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const totalCopy = `${rows.length} open ${rows.length === 1 ? "pull request" : "pull requests"}; ${rawRows.length} linked to ${data?.workspaceCount ?? 0} workspaces`;
|
|
645
|
+
const emptyCopy = search
|
|
646
|
+
? "No pull requests match this search."
|
|
647
|
+
: activeOnly
|
|
648
|
+
? "No pull requests have a running or initializing agent."
|
|
649
|
+
: savedView
|
|
650
|
+
? `No pull requests match the ${SAVED_VIEW_TITLES[savedView].toLowerCase()} view.`
|
|
651
|
+
: selected
|
|
652
|
+
? `No pull requests are ${BUCKET_TITLES[selected].toLowerCase()}.`
|
|
653
|
+
: "No open pull requests are visible to GitHub or linked to a Paseo workspace.";
|
|
654
|
+
const summaryMetrics = [
|
|
655
|
+
{ label: "Action now", value: counts["needs-you"] },
|
|
656
|
+
{ label: "Ready", value: counts.ready },
|
|
657
|
+
{ label: "Handled", value: counts["being-handled"] },
|
|
658
|
+
{ label: "Waiting", value: counts.waiting },
|
|
659
|
+
{ label: "Updates", value: viewerData?.updates ?? 0 },
|
|
660
|
+
{ label: "Agent PRs", value: activeCount },
|
|
661
|
+
];
|
|
662
|
+
|
|
663
|
+
const header = (
|
|
664
|
+
<View style={styles.header}>
|
|
665
|
+
<View style={styles.signalLine}>
|
|
666
|
+
<View style={styles.signalLabel}>
|
|
667
|
+
<View style={styles.signalDot} />
|
|
668
|
+
<Text style={styles.eyebrow}>PR RADAR · {viewerData?.viewer ?? "GITHUB"}</Text>
|
|
669
|
+
</View>
|
|
670
|
+
<Pressable
|
|
671
|
+
accessibilityRole="button"
|
|
672
|
+
accessibilityLabel="Refresh pull request status"
|
|
673
|
+
accessibilityState={{ busy: isFetching }}
|
|
674
|
+
disabled={isFetching}
|
|
675
|
+
onPress={() => void Promise.all([refetch(), refetchViewer()])}
|
|
676
|
+
style={({ pressed }) => [styles.refresh, pressed && styles.refreshPressed]}
|
|
677
|
+
>
|
|
678
|
+
<Text style={styles.refreshText}>{isFetching ? "Scanning" : "Refresh"}</Text>
|
|
679
|
+
</Pressable>
|
|
680
|
+
</View>
|
|
681
|
+
<Text style={styles.heroTitle}>Know what moves next.</Text>
|
|
682
|
+
<Text style={styles.heroDetail}>{totalCopy}</Text>
|
|
683
|
+
<View accessibilityRole="summary" style={styles.summary}>
|
|
684
|
+
{summaryMetrics.map(({ label, value }) => (
|
|
685
|
+
<View key={label} style={styles.metric}>
|
|
686
|
+
<Text style={styles.metricValue}>{value}</Text>
|
|
687
|
+
<Text style={styles.metricLabel}>{label}</Text>
|
|
688
|
+
</View>
|
|
689
|
+
))}
|
|
690
|
+
</View>
|
|
691
|
+
<View accessibilityRole="tablist" style={styles.chips}>
|
|
692
|
+
<Pressable
|
|
693
|
+
accessibilityRole="tab"
|
|
694
|
+
accessibilityState={{ selected: selected === null && !activeOnly && !savedView }}
|
|
695
|
+
onPress={() => {
|
|
696
|
+
setSelected(null);
|
|
697
|
+
setActiveOnly(false);
|
|
698
|
+
setSavedView(null);
|
|
699
|
+
}}
|
|
700
|
+
style={[styles.chip, selected === null && !activeOnly && !savedView && styles.chipActive]}
|
|
701
|
+
>
|
|
702
|
+
<Text
|
|
703
|
+
style={[
|
|
704
|
+
styles.chipText,
|
|
705
|
+
selected === null && !activeOnly && !savedView && styles.chipTextActive,
|
|
706
|
+
]}
|
|
707
|
+
>
|
|
708
|
+
All {rows.length}
|
|
709
|
+
</Text>
|
|
710
|
+
</Pressable>
|
|
711
|
+
{BUCKETS.map((bucket) => (
|
|
712
|
+
<Pressable
|
|
713
|
+
accessibilityRole="tab"
|
|
714
|
+
accessibilityState={{ selected: selected === bucket }}
|
|
715
|
+
key={bucket}
|
|
716
|
+
onPress={() => {
|
|
717
|
+
setSelected(bucket);
|
|
718
|
+
setActiveOnly(false);
|
|
719
|
+
setSavedView(null);
|
|
720
|
+
}}
|
|
721
|
+
style={[styles.chip, selected === bucket && styles.chipActive]}
|
|
722
|
+
>
|
|
723
|
+
<Text style={[styles.chipText, selected === bucket && styles.chipTextActive]}>
|
|
724
|
+
{BUCKET_TITLES[bucket]} {counts[bucket]}
|
|
725
|
+
</Text>
|
|
726
|
+
</Pressable>
|
|
727
|
+
))}
|
|
728
|
+
<Pressable
|
|
729
|
+
accessibilityRole="tab"
|
|
730
|
+
accessibilityState={{ selected: activeOnly }}
|
|
731
|
+
onPress={() => {
|
|
732
|
+
setSelected(null);
|
|
733
|
+
setActiveOnly(true);
|
|
734
|
+
setSavedView(null);
|
|
735
|
+
}}
|
|
736
|
+
style={[styles.chip, activeOnly && styles.chipActive]}
|
|
737
|
+
>
|
|
738
|
+
<Text style={[styles.chipText, activeOnly && styles.chipTextActive]}>
|
|
739
|
+
Active agents {activeCount}
|
|
740
|
+
</Text>
|
|
741
|
+
</Pressable>
|
|
742
|
+
{(Object.keys(SAVED_VIEW_TITLES) as SavedView[]).map((view) => (
|
|
743
|
+
<Pressable
|
|
744
|
+
accessibilityRole="tab"
|
|
745
|
+
accessibilityState={{ selected: savedView === view }}
|
|
746
|
+
key={view}
|
|
747
|
+
onPress={() => {
|
|
748
|
+
setSelected(null);
|
|
749
|
+
setActiveOnly(false);
|
|
750
|
+
setSavedView(view);
|
|
751
|
+
}}
|
|
752
|
+
style={[styles.chip, savedView === view && styles.chipActive]}
|
|
753
|
+
>
|
|
754
|
+
<Text style={[styles.chipText, savedView === view && styles.chipTextActive]}>
|
|
755
|
+
{SAVED_VIEW_TITLES[view]} {savedViewCounts[view]}
|
|
756
|
+
</Text>
|
|
757
|
+
</Pressable>
|
|
758
|
+
))}
|
|
759
|
+
{([7, 30, 90] as const).map((days) => (
|
|
760
|
+
<Pressable
|
|
761
|
+
accessibilityRole="button"
|
|
762
|
+
accessibilityState={{ selected: windowDays === days }}
|
|
763
|
+
key={days}
|
|
764
|
+
onPress={() => setWindowDays(days)}
|
|
765
|
+
style={[styles.chip, windowDays === days && styles.chipActive]}
|
|
766
|
+
>
|
|
767
|
+
<Text style={[styles.chipText, windowDays === days && styles.chipTextActive]}>
|
|
768
|
+
{days}d
|
|
769
|
+
</Text>
|
|
770
|
+
</Pressable>
|
|
771
|
+
))}
|
|
772
|
+
</View>
|
|
773
|
+
<TextInput
|
|
774
|
+
accessibilityLabel="Filter pull requests"
|
|
775
|
+
autoCapitalize="none"
|
|
776
|
+
autoCorrect={false}
|
|
777
|
+
onChangeText={setSearch}
|
|
778
|
+
placeholder="Filter by PR, branch, workspace, or agent"
|
|
779
|
+
placeholderTextColor={theme.colors.foregroundMuted}
|
|
780
|
+
style={styles.search}
|
|
781
|
+
value={search}
|
|
782
|
+
/>
|
|
783
|
+
{viewerData ? (
|
|
784
|
+
<View style={{ gap: 7 }}>
|
|
785
|
+
<Text style={styles.heroDetail}>
|
|
786
|
+
{viewerData.coverageNote}
|
|
787
|
+
{viewerData.truncated ? " Results reached the 100-item inbox cap." : ""}
|
|
788
|
+
</Text>
|
|
789
|
+
{viewerData.updates > 0 ? (
|
|
790
|
+
<Text style={styles.heroDetail}>
|
|
791
|
+
These are PR state changes detected in the {windowDays}-day view. Marking them seen
|
|
792
|
+
only clears PR Radar badges; it does not change GitHub notifications or pull requests.
|
|
793
|
+
</Text>
|
|
794
|
+
) : null}
|
|
795
|
+
{viewerData.updates > 0 ? (
|
|
796
|
+
<Pressable
|
|
797
|
+
accessibilityRole="button"
|
|
798
|
+
accessibilityLabel={`Mark ${viewerData.updates} detected pull request updates as seen`}
|
|
799
|
+
accessibilityState={{ busy: acknowledgeMutation.isPending }}
|
|
800
|
+
disabled={acknowledgeMutation.isPending}
|
|
801
|
+
onPress={() => acknowledgeMutation.mutate()}
|
|
802
|
+
style={({ pressed }) => [styles.refresh, pressed && styles.refreshPressed]}
|
|
803
|
+
>
|
|
804
|
+
<Text style={styles.refreshText}>
|
|
805
|
+
{acknowledgeMutation.isPending
|
|
806
|
+
? "Marking…"
|
|
807
|
+
: `Mark ${viewerData.updates} updates seen`}
|
|
808
|
+
</Text>
|
|
809
|
+
</Pressable>
|
|
810
|
+
) : null}
|
|
811
|
+
</View>
|
|
812
|
+
) : null}
|
|
813
|
+
{actionNotice ? <Text style={styles.notice}>{actionNotice}</Text> : null}
|
|
814
|
+
{openError ? <Text style={styles.error}>{openError}</Text> : null}
|
|
815
|
+
{error ? (
|
|
816
|
+
<Text accessibilityRole="alert" style={styles.error}>
|
|
817
|
+
{error instanceof Error ? error.message : "Could not load the delivery queue."}
|
|
818
|
+
</Text>
|
|
819
|
+
) : null}
|
|
820
|
+
{viewerError ? (
|
|
821
|
+
<Text accessibilityRole="alert" style={styles.error}>
|
|
822
|
+
GitHub viewer identity is unavailable. Action buckets are conservative.
|
|
823
|
+
</Text>
|
|
824
|
+
) : null}
|
|
825
|
+
</View>
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
return (
|
|
829
|
+
<View style={styles.screen}>
|
|
830
|
+
<FlatList
|
|
831
|
+
contentContainerStyle={styles.content}
|
|
832
|
+
data={visibleRows}
|
|
833
|
+
keyExtractor={(item) => item.id}
|
|
834
|
+
ListHeaderComponent={header}
|
|
835
|
+
ListEmptyComponent={
|
|
836
|
+
isPending ? (
|
|
837
|
+
<ActivityIndicator color={theme.colors.accent} style={styles.spinner} />
|
|
838
|
+
) : (
|
|
839
|
+
<View style={styles.empty}>
|
|
840
|
+
<Text style={styles.emptyTitle}>
|
|
841
|
+
{rows.length === 0 ? "Clear runway" : "Nothing here"}
|
|
842
|
+
</Text>
|
|
843
|
+
<Text style={styles.emptyDetail}>{emptyCopy}</Text>
|
|
844
|
+
</View>
|
|
845
|
+
)
|
|
846
|
+
}
|
|
847
|
+
renderItem={renderRow}
|
|
848
|
+
/>
|
|
849
|
+
{data?.warnings.length ? (
|
|
850
|
+
<View style={styles.warning}>
|
|
851
|
+
<Text style={styles.warningText} numberOfLines={2}>
|
|
852
|
+
{data.warnings.length}{" "}
|
|
853
|
+
{data.warnings.length === 1 ? "workspace has" : "workspaces have"} unavailable pull
|
|
854
|
+
request status. Other results are current.
|
|
855
|
+
</Text>
|
|
856
|
+
</View>
|
|
857
|
+
) : null}
|
|
858
|
+
</View>
|
|
859
|
+
);
|
|
860
|
+
}
|