@omercnet/paseo-pr-radar 1.1.0 → 1.2.0-next.214.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/README.md CHANGED
@@ -32,6 +32,10 @@ DevTools before capture. Both PNGs come from an isolated Paseo test daemon at 2
32
32
 
33
33
  Paseo supplies normalized workspace pull request status. A daemon-side plugin handler uses the authenticated `gh` CLI to distinguish authored pull requests from review requests. If viewer lookup fails, PR Radar falls back conservatively and does not claim that a row needs the user.
34
34
 
35
+ ## Paseo 0.11
36
+
37
+ On 0.11 the sidebar row shows a "needs you" count; pressing the count opens a popover of those pull requests. Pressing one opens PR Radar focused on it (`param.pr`), and the needs-you link filters the queue (`param.filter`), so both survive reload and can be linked. A trailing `+` marks a count that may be incomplete: viewer lookup failed, a pagination or inbox cap was hit, or workspace status was unavailable. Paseo 0.9 and 0.10 keep the static surface and sidebar row.
38
+
35
39
  ## Install
36
40
 
37
41
  ### Paseo 0.9 and 0.10
@@ -66,7 +70,7 @@ bun run typecheck
66
70
  Release Please maintains the version, changelog, component tag, and GitHub release from
67
71
  Conventional Commits in the monorepo.
68
72
 
69
- The manifest supports Paseo `^0.9.0 || ^0.10.0`. Development uses the stable 0.9 CLI, client, plugin SDK,
73
+ The manifest supports Paseo `^0.9.0 || ^0.10.0 || ^0.11.0`. Development uses the stable 0.9 CLI, client, plugin SDK,
70
74
  and protocol packages together. Host-owned navigation opens linked agents and workspaces without private routes or
71
75
  page reloads on web, desktop, iOS, and Android. React `19.1` and React Native `0.81` match the versions supplied by
72
76
  the plugin host.
@@ -1,38 +1,30 @@
1
- import {
2
- openExternalUrl,
3
- type PluginSurfaceProps,
4
- usePaseo,
5
- useRpc,
6
- } from "@getpaseo/plugin/client";
7
- import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1
+ import { openExternalUrl, type PluginSurfaceProps, useRpc } from "@getpaseo/plugin/client";
2
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
8
3
  import { useCallback, useEffect, useMemo, useState } from "react";
9
4
  import { ActivityIndicator, FlatList, Pressable, Text, TextInput, View } from "react-native";
10
- import { acknowledgeViewerScope, viewerScope } from "../shared/viewer-scope";
11
- import { observeDirectoryInvalidation } from "./directory-observation";
5
+ import { acknowledgeViewerScope } from "../shared/viewer-scope";
12
6
  import {
13
- type AgentEntry,
14
7
  agentActionFor,
15
- applyViewerScope,
16
8
  BUCKET_TITLES,
17
9
  BUCKETS,
18
10
  buildAgentPrompt,
19
- buildRadarSnapshot,
20
11
  checkSummary,
21
12
  formatAge,
22
13
  hasActiveAgent,
23
14
  matchesRow,
24
- mergeInboxRows,
25
15
  openPullRequestUrl,
26
- type PaseoApi,
27
- type PaseoWorkspace,
28
16
  type RadarBucket,
29
17
  type RadarRow,
30
18
  } from "./radar";
19
+ import {
20
+ DIRECTORY_PARTIAL,
21
+ LOOKUP_CAPPED,
22
+ parseRadarParams,
23
+ type RadarFilter,
24
+ VIEWER_URL_LIMIT,
25
+ } from "./screen-state";
26
+ import { useRadar } from "./use-radar";
31
27
 
32
- const PAGE_LIMIT = 200;
33
- const MAX_PAGES = 10;
34
- const BACKSTOP_REFRESH_MS = 60_000;
35
- const EVENT_DEBOUNCE_MS = 500;
36
28
  const CLOCK_TICK_MS = 30_000;
37
29
 
38
30
  type SavedView = "security" | "updated" | "stale" | "automation";
@@ -46,35 +38,6 @@ const SAVED_VIEW_TITLES: Record<SavedView, string> = {
46
38
 
47
39
  const STALE_AFTER_MS = 3 * 24 * 60 * 60 * 1_000;
48
40
 
49
- async function loadAgents(paseo: PaseoApi): Promise<AgentEntry[]> {
50
- const entries: AgentEntry[] = [];
51
- let cursor: string | undefined;
52
- for (let page = 0; page < MAX_PAGES; page += 1) {
53
- const result = await paseo.agents.list({
54
- sort: [{ key: "updated_at", direction: "desc" }],
55
- page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
56
- });
57
- entries.push(...result.entries);
58
- cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined;
59
- if (!cursor) break;
60
- }
61
- return entries;
62
- }
63
-
64
- async function loadWorkspaces(paseo: PaseoApi): Promise<PaseoWorkspace[]> {
65
- const workspaces: PaseoWorkspace[] = [];
66
- let cursor: string | undefined;
67
- for (let page = 0; page < MAX_PAGES; page += 1) {
68
- const result = await paseo.workspaces.list({
69
- page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
70
- });
71
- workspaces.push(...result.entries);
72
- cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined;
73
- if (!cursor) break;
74
- }
75
- return workspaces;
76
- }
77
-
78
41
  function bucketColor(bucket: RadarBucket, colors: PluginSurfaceProps["theme"]["colors"]): string {
79
42
  if (bucket === "needs-you") return colors.statusDanger;
80
43
  if (bucket === "ready") return colors.statusSuccess;
@@ -93,15 +56,29 @@ function agentState(row: RadarRow): string {
93
56
  return `${agent.title} · ${agent.status}${extra}`;
94
57
  }
95
58
 
96
- export function PrRadar({ theme, layout, host, navigation }: PluginSurfaceProps) {
97
- const paseo = usePaseo();
59
+ export function PrRadar({
60
+ theme,
61
+ layout,
62
+ host,
63
+ navigation,
64
+ params = {},
65
+ }: PluginSurfaceProps & { params?: Record<string, string> }) {
98
66
  const queryClient = useQueryClient();
99
- const queryKey = useMemo(() => ["pr-radar", host.id], [host.id]);
100
- const resolveViewerScope = useRpc(viewerScope);
101
67
  const acknowledgeUpdates = useRpc(acknowledgeViewerScope);
102
- const [selected, setSelected] = useState<RadarBucket | null>(null);
103
- const [activeOnly, setActiveOnly] = useState(false);
104
- const [savedView, setSavedView] = useState<SavedView | null>(null);
68
+ const parsed = parseRadarParams(params);
69
+ const [filter, setFilterState] = useState<RadarFilter | null>(parsed.filter);
70
+ const [focusedPr, setFocusedPr] = useState<string | null>(parsed.pr);
71
+ // Chips are local state: openScreen would push a new screen per tap. A chip press also ends PR focus.
72
+ const setFilter = (value: RadarFilter | null) => {
73
+ setFilterState(value);
74
+ setFocusedPr(null);
75
+ };
76
+ const selected = BUCKETS.includes(filter as RadarBucket) ? (filter as RadarBucket) : null;
77
+ const activeOnly = filter === "active";
78
+ const savedView =
79
+ filter && ["security", "updated", "stale", "automation"].includes(filter)
80
+ ? (filter as SavedView)
81
+ : null;
105
82
  const [windowDays, setWindowDays] = useState(30);
106
83
  const [search, setSearch] = useState("");
107
84
  const [now, setNow] = useState(() => Date.now());
@@ -109,64 +86,25 @@ export function PrRadar({ theme, layout, host, navigation }: PluginSurfaceProps)
109
86
  const [actionNotice, setActionNotice] = useState<string | null>(null);
110
87
 
111
88
  const {
89
+ paseo,
90
+ queryKey,
112
91
  data,
113
92
  error,
114
93
  isPending,
115
- isFetching: isDirectoryFetching,
116
94
  refetch,
117
- } = useQuery({
118
- queryKey,
119
- queryFn: async () => {
120
- const [workspaces, agents] = await Promise.all([loadWorkspaces(paseo), loadAgents(paseo)]);
121
- return buildRadarSnapshot(workspaces, agents);
122
- },
123
- refetchInterval: BACKSTOP_REFRESH_MS,
124
- });
95
+ rawRows,
96
+ rows,
97
+ viewerData,
98
+ viewerError,
99
+ isFetching,
100
+ refetchViewer,
101
+ } = useRadar(host.id, windowDays);
125
102
 
126
103
  useEffect(() => {
127
104
  const clock = setInterval(() => setNow(Date.now()), CLOCK_TICK_MS);
128
105
  return () => clearInterval(clock);
129
106
  }, []);
130
107
 
131
- useEffect(
132
- () =>
133
- observeDirectoryInvalidation(
134
- paseo,
135
- () => void queryClient.invalidateQueries({ queryKey }),
136
- EVENT_DEBOUNCE_MS,
137
- ),
138
- [paseo, queryClient, queryKey],
139
- );
140
-
141
- const rawRows = data?.rows ?? [];
142
- const scopeUrls = useMemo(() => rawRows.map((row) => row.url), [rawRows]);
143
- const {
144
- data: viewerData,
145
- error: viewerQueryError,
146
- isFetching: isViewerFetching,
147
- refetch: refetchViewer,
148
- } = useQuery({
149
- queryKey: ["pr-radar-viewer-scope", host.id, scopeUrls, windowDays],
150
- queryFn: () => resolveViewerScope({ urls: scopeUrls, windowDays }),
151
- staleTime: 5 * 60_000,
152
- refetchInterval: 5 * 60_000,
153
- });
154
- const mergedRows = useMemo(
155
- () => (data ? mergeInboxRows(data, viewerData?.inboxItems ?? []) : []),
156
- [data, viewerData],
157
- );
158
- const rows = useMemo(
159
- () => applyViewerScope(mergedRows, viewerData ?? null),
160
- [mergedRows, viewerData],
161
- );
162
- const isFetching = isDirectoryFetching || isViewerFetching;
163
- const viewerError =
164
- viewerData?.error ??
165
- (viewerQueryError instanceof Error
166
- ? viewerQueryError.message
167
- : viewerQueryError
168
- ? "error"
169
- : null);
170
108
  const acknowledgeMutation = useMutation({
171
109
  mutationFn: () => acknowledgeUpdates({ windowDays }),
172
110
  onSuccess: async () => {
@@ -282,13 +220,14 @@ export function PrRadar({ theme, layout, host, navigation }: PluginSurfaceProps)
282
220
  Boolean(row.activityAt && now - Date.parse(row.activityAt) >= STALE_AFTER_MS)) ||
283
221
  (savedView === "automation" && row.authorKind === "bot");
284
222
  return (
223
+ (!focusedPr || row.id === focusedPr) &&
285
224
  (!selected || row.bucket === selected) &&
286
225
  (!activeOnly || hasActiveAgent(row.agents)) &&
287
226
  matchesSavedView &&
288
227
  matchesRow(row, search)
289
228
  );
290
229
  }),
291
- [activeOnly, now, rows, savedView, search, selected],
230
+ [activeOnly, now, rows, savedView, search, selected, focusedPr],
292
231
  );
293
232
 
294
233
  const styles = useMemo(() => {
@@ -642,15 +581,17 @@ export function PrRadar({ theme, layout, host, navigation }: PluginSurfaceProps)
642
581
  };
643
582
 
644
583
  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.";
584
+ const emptyCopy = focusedPr
585
+ ? "This pull request is not in the current queue."
586
+ : search
587
+ ? "No pull requests match this search."
588
+ : activeOnly
589
+ ? "No pull requests have a running or initializing agent."
590
+ : savedView
591
+ ? `No pull requests match the ${SAVED_VIEW_TITLES[savedView].toLowerCase()} view.`
592
+ : selected
593
+ ? `No pull requests are ${BUCKET_TITLES[selected].toLowerCase()}.`
594
+ : "No open pull requests are visible to GitHub or linked to a Paseo workspace.";
654
595
  const summaryMetrics = [
655
596
  { label: "Action now", value: counts["needs-you"] },
656
597
  { label: "Ready", value: counts.ready },
@@ -680,6 +621,11 @@ export function PrRadar({ theme, layout, host, navigation }: PluginSurfaceProps)
680
621
  </View>
681
622
  <Text style={styles.heroTitle}>Know what moves next.</Text>
682
623
  <Text style={styles.heroDetail}>{totalCopy}</Text>
624
+ {focusedPr ? <Text style={styles.heroDetail}>Focused PR: {focusedPr}</Text> : null}
625
+ {data?.truncated ? <Text style={styles.warningText}>{DIRECTORY_PARTIAL}</Text> : null}
626
+ {rawRows.length > VIEWER_URL_LIMIT ? (
627
+ <Text style={styles.warningText}>{LOOKUP_CAPPED}</Text>
628
+ ) : null}
683
629
  <View accessibilityRole="summary" style={styles.summary}>
684
630
  {summaryMetrics.map(({ label, value }) => (
685
631
  <View key={label} style={styles.metric}>
@@ -691,18 +637,19 @@ export function PrRadar({ theme, layout, host, navigation }: PluginSurfaceProps)
691
637
  <View accessibilityRole="tablist" style={styles.chips}>
692
638
  <Pressable
693
639
  accessibilityRole="tab"
694
- accessibilityState={{ selected: selected === null && !activeOnly && !savedView }}
695
- onPress={() => {
696
- setSelected(null);
697
- setActiveOnly(false);
698
- setSavedView(null);
640
+ accessibilityState={{
641
+ selected: selected === null && !activeOnly && !savedView && !focusedPr,
699
642
  }}
700
- style={[styles.chip, selected === null && !activeOnly && !savedView && styles.chipActive]}
643
+ onPress={() => setFilter(null)}
644
+ style={[
645
+ styles.chip,
646
+ selected === null && !activeOnly && !savedView && !focusedPr && styles.chipActive,
647
+ ]}
701
648
  >
702
649
  <Text
703
650
  style={[
704
651
  styles.chipText,
705
- selected === null && !activeOnly && !savedView && styles.chipTextActive,
652
+ selected === null && !activeOnly && !savedView && !focusedPr && styles.chipTextActive,
706
653
  ]}
707
654
  >
708
655
  All {rows.length}
@@ -713,11 +660,7 @@ export function PrRadar({ theme, layout, host, navigation }: PluginSurfaceProps)
713
660
  accessibilityRole="tab"
714
661
  accessibilityState={{ selected: selected === bucket }}
715
662
  key={bucket}
716
- onPress={() => {
717
- setSelected(bucket);
718
- setActiveOnly(false);
719
- setSavedView(null);
720
- }}
663
+ onPress={() => setFilter(bucket)}
721
664
  style={[styles.chip, selected === bucket && styles.chipActive]}
722
665
  >
723
666
  <Text style={[styles.chipText, selected === bucket && styles.chipTextActive]}>
@@ -728,11 +671,7 @@ export function PrRadar({ theme, layout, host, navigation }: PluginSurfaceProps)
728
671
  <Pressable
729
672
  accessibilityRole="tab"
730
673
  accessibilityState={{ selected: activeOnly }}
731
- onPress={() => {
732
- setSelected(null);
733
- setActiveOnly(true);
734
- setSavedView(null);
735
- }}
674
+ onPress={() => setFilter("active")}
736
675
  style={[styles.chip, activeOnly && styles.chipActive]}
737
676
  >
738
677
  <Text style={[styles.chipText, activeOnly && styles.chipTextActive]}>
@@ -744,11 +683,7 @@ export function PrRadar({ theme, layout, host, navigation }: PluginSurfaceProps)
744
683
  accessibilityRole="tab"
745
684
  accessibilityState={{ selected: savedView === view }}
746
685
  key={view}
747
- onPress={() => {
748
- setSelected(null);
749
- setActiveOnly(false);
750
- setSavedView(view);
751
- }}
686
+ onPress={() => setFilter(view)}
752
687
  style={[styles.chip, savedView === view && styles.chipActive]}
753
688
  >
754
689
  <Text style={[styles.chipText, savedView === view && styles.chipTextActive]}>
@@ -0,0 +1,64 @@
1
+ import { BUCKETS, type RadarBucket, type RadarRow } from "./radar";
2
+
3
+ export type RadarFilter = RadarBucket | "active" | "security" | "updated" | "stale" | "automation";
4
+ const FILTERS: readonly string[] = [
5
+ ...BUCKETS,
6
+ "active",
7
+ "security",
8
+ "updated",
9
+ "stale",
10
+ "automation",
11
+ ];
12
+
13
+ export function parseRadarParams(params: Record<string, string>) {
14
+ return {
15
+ filter: FILTERS.includes(params.filter) ? (params.filter as RadarFilter) : null,
16
+ pr: params.pr?.toLowerCase() || null,
17
+ };
18
+ }
19
+
20
+ export const VIEWER_URL_LIMIT = 200;
21
+ export const DIRECTORY_PARTIAL = "Directory pagination limit reached; results are partial.";
22
+ export const LOOKUP_CAPPED = "Viewer lookup is limited to 200 linked pull requests.";
23
+
24
+ /** Reasons the queue may be incomplete. Any entry makes the sidebar count a lower bound ("N+"). */
25
+ export function radarWarnings(input: {
26
+ directoryError: boolean;
27
+ directoryTruncated: boolean;
28
+ workspaceWarnings: number;
29
+ viewerKnown: boolean;
30
+ viewerTruncated: boolean;
31
+ urlCount: number;
32
+ }) {
33
+ const warnings: string[] = [];
34
+ if (input.directoryError) warnings.push("Could not load the delivery queue.");
35
+ if (input.directoryTruncated) warnings.push(DIRECTORY_PARTIAL);
36
+ if (input.workspaceWarnings > 0)
37
+ warnings.push("Some workspaces have unavailable pull request status.");
38
+ if (!input.viewerKnown)
39
+ warnings.push("GitHub viewer identity is unavailable. Action buckets are conservative.");
40
+ if (input.urlCount > VIEWER_URL_LIMIT) warnings.push(LOOKUP_CAPPED);
41
+ if (input.viewerTruncated) warnings.push("Results reached the 100-item inbox cap.");
42
+ return warnings;
43
+ }
44
+
45
+ export function needsYouSummary(
46
+ rows: readonly RadarRow[],
47
+ loading: boolean,
48
+ warnings: readonly string[],
49
+ ) {
50
+ const items = rows.filter((row) => row.bucket === "needs-you");
51
+ return { items, label: loading ? "…" : `${items.length}${warnings.length ? "+" : ""}` };
52
+ }
53
+
54
+ export function supportsRadarScreen(
55
+ client: { addScreen?: unknown; addSidebarHeaderItem?: unknown; openScreen?: unknown },
56
+ sidebarRow: unknown,
57
+ ) {
58
+ return (
59
+ typeof client.addScreen === "function" &&
60
+ typeof client.addSidebarHeaderItem === "function" &&
61
+ typeof client.openScreen === "function" &&
62
+ typeof sidebarRow === "function"
63
+ );
64
+ }
@@ -0,0 +1,99 @@
1
+ import type { PluginPopoverProps, PluginSidebarItemProps } from "@getpaseo/plugin/client";
2
+ import { SidebarRow } from "@getpaseo/plugin/client/ui";
3
+ import { Pressable, ScrollView, Text } from "react-native";
4
+ import { needsYouSummary } from "./screen-state";
5
+ import { useRadar } from "./use-radar";
6
+
7
+ function NeedsYouPopover({ theme, layout, host, openScreen }: PluginPopoverProps) {
8
+ const queue = useRadar(host.id, 30, false);
9
+ const { items } = needsYouSummary(queue.rows, queue.loading, queue.warnings);
10
+ return (
11
+ <ScrollView
12
+ style={{ maxHeight: layout.compact ? 460 : 400 }}
13
+ contentContainerStyle={{ padding: 16, gap: 12 }}
14
+ >
15
+ <Text style={{ color: theme.colors.foreground, fontSize: 18, fontWeight: "700" }}>
16
+ Needs you
17
+ </Text>
18
+ {queue.viewerData ? (
19
+ <Text style={{ color: theme.colors.foregroundMuted }}>{queue.viewerData.coverageNote}</Text>
20
+ ) : null}
21
+ {queue.warnings.map((warning) => (
22
+ <Text key={warning} style={{ color: theme.colors.statusWarning }}>
23
+ {warning}
24
+ </Text>
25
+ ))}
26
+ {queue.loading ? (
27
+ <Text style={{ color: theme.colors.foregroundMuted }}>Scanning pull requests…</Text>
28
+ ) : null}
29
+ {!queue.loading && !items.length ? (
30
+ <Text style={{ color: theme.colors.foregroundMuted }}>
31
+ {queue.warnings.length
32
+ ? "No known items need you in the available results."
33
+ : "No pull requests need you in this scope."}
34
+ </Text>
35
+ ) : null}
36
+ {!queue.loading &&
37
+ items.map((row) => (
38
+ <Pressable
39
+ key={row.id}
40
+ accessibilityRole="button"
41
+ onPress={() => openScreen({ screenId: "radar", params: { pr: row.id } })}
42
+ style={{
43
+ paddingVertical: 10,
44
+ borderTopWidth: 1,
45
+ borderColor: theme.colors.border,
46
+ gap: 4,
47
+ }}
48
+ >
49
+ <Text style={{ color: theme.colors.foreground, fontWeight: "600" }}>
50
+ {row.repository}#{row.number} · {row.title}
51
+ </Text>
52
+ <Text style={{ color: theme.colors.foregroundMuted }}>{row.reason}</Text>
53
+ </Pressable>
54
+ ))}
55
+ <Pressable
56
+ accessibilityRole="button"
57
+ onPress={() => openScreen({ screenId: "radar", params: { filter: "needs-you" } })}
58
+ style={{ paddingVertical: 10 }}
59
+ >
60
+ <Text style={{ color: theme.colors.accent }}>Open needs-you queue</Text>
61
+ </Pressable>
62
+ </ScrollView>
63
+ );
64
+ }
65
+
66
+ export function RadarSidebar({
67
+ theme,
68
+ host,
69
+ currentScreen,
70
+ openScreen,
71
+ openPopover,
72
+ }: PluginSidebarItemProps) {
73
+ const queue = useRadar(host.id, 30, currentScreen?.screenId !== "radar");
74
+ const summary = needsYouSummary(queue.rows, queue.loading, queue.warnings);
75
+ return (
76
+ <SidebarRow
77
+ icon="GitPullRequest"
78
+ active={currentScreen?.screenId === "radar"}
79
+ onPress={() => openScreen({ screenId: "radar" })}
80
+ trailing={
81
+ <Pressable
82
+ accessibilityRole="button"
83
+ accessibilityLabel={`Needs you: ${summary.label}${queue.warnings.length ? "; incomplete results" : ""}`}
84
+ onPress={() => openPopover(NeedsYouPopover)}
85
+ style={{ paddingHorizontal: 8, paddingVertical: 6 }}
86
+ >
87
+ <Text
88
+ style={{
89
+ color: queue.warnings.length ? theme.colors.statusWarning : theme.colors.foreground,
90
+ fontSize: 12,
91
+ }}
92
+ >
93
+ {summary.label} needs you
94
+ </Text>
95
+ </Pressable>
96
+ }
97
+ />
98
+ );
99
+ }
@@ -0,0 +1,130 @@
1
+ import { usePaseo, useRpc } from "@getpaseo/plugin/client";
2
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { useEffect, useMemo } from "react";
4
+ import { viewerScope } from "../shared/viewer-scope";
5
+ import { observeDirectoryInvalidation } from "./directory-observation";
6
+ import {
7
+ type AgentEntry,
8
+ applyViewerScope,
9
+ buildRadarSnapshot,
10
+ mergeInboxRows,
11
+ type PaseoApi,
12
+ type PaseoWorkspace,
13
+ } from "./radar";
14
+ import { radarWarnings, VIEWER_URL_LIMIT } from "./screen-state";
15
+
16
+ const PAGE_LIMIT = 200;
17
+ const MAX_PAGES = 10;
18
+
19
+ async function loadAgents(paseo: PaseoApi) {
20
+ const entries: AgentEntry[] = [];
21
+ let cursor: string | undefined;
22
+ let truncated = false;
23
+ for (let page = 0; page < MAX_PAGES; page += 1) {
24
+ const result = await paseo.agents.list({
25
+ sort: [{ key: "updated_at", direction: "desc" }],
26
+ page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
27
+ });
28
+ entries.push(...result.entries);
29
+ truncated = result.pageInfo.hasMore;
30
+ cursor = result.pageInfo.nextCursor ?? undefined;
31
+ if (!truncated || !cursor) break;
32
+ }
33
+ return { entries, truncated };
34
+ }
35
+
36
+ async function loadWorkspaces(paseo: PaseoApi) {
37
+ const entries: PaseoWorkspace[] = [];
38
+ let cursor: string | undefined;
39
+ let truncated = false;
40
+ for (let page = 0; page < MAX_PAGES; page += 1) {
41
+ const result = await paseo.workspaces.list({
42
+ page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
43
+ });
44
+ entries.push(...result.entries);
45
+ truncated = result.pageInfo.hasMore;
46
+ cursor = result.pageInfo.nextCursor ?? undefined;
47
+ if (!truncated || !cursor) break;
48
+ }
49
+ return { entries, truncated };
50
+ }
51
+
52
+ // `live` owns the refresh timers. The sidebar is live unless the screen is open (the screen is
53
+ // then live); popovers only observe the cache.
54
+ export function useRadar(hostId: string, windowDays = 30, live = true) {
55
+ const paseo = usePaseo();
56
+ const queryClient = useQueryClient();
57
+ const resolveViewerScope = useRpc(viewerScope);
58
+ const queryKey = useMemo(() => ["pr-radar", hostId], [hostId]);
59
+ const directory = useQuery({
60
+ queryKey,
61
+ queryFn: async () => {
62
+ const [workspaces, agents] = await Promise.all([loadWorkspaces(paseo), loadAgents(paseo)]);
63
+ return {
64
+ ...buildRadarSnapshot(workspaces.entries, agents.entries),
65
+ truncated: workspaces.truncated || agents.truncated,
66
+ };
67
+ },
68
+ staleTime: 60_000,
69
+ refetchInterval: live ? 60_000 : false,
70
+ });
71
+ useEffect(() => {
72
+ if (!live) return;
73
+ return observeDirectoryInvalidation(
74
+ paseo,
75
+ () => void queryClient.invalidateQueries({ queryKey }),
76
+ 500,
77
+ );
78
+ }, [live, paseo, queryClient, queryKey]);
79
+ const rawRows = directory.data?.rows ?? [];
80
+ // One deduped, ordered, capped list feeds both the query key and the request.
81
+ const scopeUrls = useMemo(
82
+ () => [...new Set(rawRows.map((row) => row.url))].sort().slice(0, VIEWER_URL_LIMIT),
83
+ [rawRows],
84
+ );
85
+ const viewer = useQuery({
86
+ queryKey: ["pr-radar-viewer-scope", hostId, scopeUrls, windowDays],
87
+ queryFn: () => resolveViewerScope({ urls: scopeUrls, windowDays }),
88
+ enabled: Boolean(directory.data),
89
+ staleTime: 5 * 60_000,
90
+ refetchInterval: live ? 5 * 60_000 : false,
91
+ });
92
+ useEffect(() => {
93
+ if (!live) return;
94
+ // Timers pause while not live; catch up on resume instead of waiting out the interval.
95
+ void queryClient.refetchQueries({ queryKey, stale: true });
96
+ void queryClient.refetchQueries({ queryKey: ["pr-radar-viewer-scope", hostId], stale: true });
97
+ }, [live, queryClient, queryKey, hostId]);
98
+ const rows = useMemo(
99
+ () =>
100
+ applyViewerScope(
101
+ directory.data ? mergeInboxRows(directory.data, viewer.data?.inboxItems ?? []) : [],
102
+ viewer.data ?? null,
103
+ ),
104
+ [directory.data, viewer.data],
105
+ );
106
+ const warnings = radarWarnings({
107
+ directoryError: Boolean(directory.error),
108
+ directoryTruncated: Boolean(directory.data?.truncated),
109
+ workspaceWarnings: directory.data?.warnings.length ?? 0,
110
+ viewerKnown: Boolean(viewer.data?.viewer) && !viewer.data?.error && !viewer.error,
111
+ viewerTruncated: Boolean(viewer.data?.truncated),
112
+ urlCount: new Set(rawRows.map((row) => row.url)).size,
113
+ });
114
+ return {
115
+ paseo,
116
+ queryKey,
117
+ rows,
118
+ rawRows,
119
+ warnings,
120
+ data: directory.data,
121
+ error: directory.error,
122
+ isPending: directory.isPending,
123
+ isFetching: directory.isFetching || viewer.isFetching,
124
+ refetch: directory.refetch,
125
+ viewerData: viewer.data,
126
+ viewerError: viewer.data?.error ?? viewer.error,
127
+ refetchViewer: viewer.refetch,
128
+ loading: directory.isPending || (Boolean(directory.data) && viewer.isPending),
129
+ };
130
+ }
package/index.client.tsx CHANGED
@@ -1,22 +1,37 @@
1
1
  import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { SidebarRow } from "@getpaseo/plugin/client/ui";
2
3
  import { PrRadar } from "./client/pr-radar";
4
+ import { supportsRadarScreen } from "./client/screen-state";
5
+ import { RadarSidebar } from "./client/sidebar";
3
6
 
4
7
  export default function contribute(client: PluginClientContext) {
5
- client.addSurface("radar", PrRadar);
6
- client.addSidebarItem({
7
- id: "radar",
8
- title: "PR Radar",
9
- icon: "GitPullRequest",
10
- surface: "radar",
11
- });
8
+ const screens = supportsRadarScreen(client, SidebarRow);
9
+ if (screens) {
10
+ client.addScreen({
11
+ id: "radar",
12
+ title: "PR Radar",
13
+ Component: PrRadar,
14
+ });
15
+ client.addSidebarHeaderItem({ id: "radar", title: "PR Radar", Component: RadarSidebar });
16
+ } else {
17
+ client.addSurface("radar", PrRadar);
18
+ client.addSidebarItem({
19
+ id: "radar",
20
+ title: "PR Radar",
21
+ icon: "GitPullRequest",
22
+ surface: "radar",
23
+ });
24
+ }
12
25
  client.addCommandCenterItem({
13
26
  id: "open-radar",
14
27
  title: "Open PR Radar",
15
28
  icon: "GitPullRequest",
16
29
  keywords: ["pull requests", "delivery", "merge", "agents"],
17
30
  context: "global",
18
- onSelect({ openSurface }) {
19
- openSurface("radar");
31
+ onSelect(capabilities) {
32
+ if (screens && typeof capabilities.openScreen === "function")
33
+ capabilities.openScreen({ screenId: "radar" });
34
+ else capabilities.openSurface("radar");
20
35
  },
21
36
  });
22
37
  return () => {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-pr-radar",
3
- "version": "1.1.0",
3
+ "version": "1.2.0-next.214.1",
4
4
  "type": "module",
5
5
  "description": "A viewer-aware delivery queue for pull requests linked to Paseo workspaces.",
6
6
  "license": "MIT",
@@ -46,10 +46,10 @@
46
46
  },
47
47
  "devDependencies": {
48
48
  "@biomejs/biome": "^2.5.10",
49
- "@getpaseo/cli": "0.10.1",
50
- "@getpaseo/client": "0.10.1",
51
- "@getpaseo/plugin": "0.10.1",
52
- "@getpaseo/protocol": "0.10.1",
49
+ "@getpaseo/cli": "0.11.0-beta.3",
50
+ "@getpaseo/client": "0.11.0-beta.3",
51
+ "@getpaseo/plugin": "0.11.0-beta.3",
52
+ "@getpaseo/protocol": "0.11.0-beta.3",
53
53
  "@tanstack/react-query": "^5.102.3",
54
54
  "@types/node": "^24.10.1",
55
55
  "@types/react": "^19.2.18",
package/paseo-plugin.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "id": "pr-radar",
3
3
  "description": "A viewer-aware delivery queue for pull requests linked to Paseo workspaces.",
4
- "requirements": { "paseo": "^0.9.0 || ^0.10.0" }
4
+ "requirements": { "paseo": "^0.9.0 || ^0.10.0 || ^0.11.0" }
5
5
  }