@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.
@@ -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
+ }