diff --git a/client/board/board-body.tsx b/client/board/board-body.tsx
index 5742403..68b0731 100644
--- a/client/board/board-body.tsx
+++ b/client/board/board-body.tsx
@@ -9,6 +9,7 @@ import { ProjectsView } from "../projects/projects-view";
import { EMPTY_PROMPTS, PromptSettingsView } from "../settings/prompt-settings-view";
import type { Styles } from "../theme/use-styles";
import { openExternalUrl } from "../web";
+import type { ChatLink } from "./use-chat-links";
/**
* Everything below the header and the toolbar: the settings screen when it is
@@ -37,6 +38,7 @@ export function BoardBody({
setBodyWidth,
detailTarget,
detailItem,
+ detailChatLink,
detailProgress,
closeDetails,
savedFraction,
@@ -63,6 +65,7 @@ export function BoardBody({
setBodyWidth: (next: number | null) => void;
detailTarget: { item: BoardItem; type: ColumnId } | null;
detailItem: BoardItem | null;
+ detailChatLink: ChatLink | null;
detailProgress: Animated.Value;
closeDetails: () => void;
savedFraction: number | null;
@@ -94,7 +97,6 @@ export function BoardBody({
@@ -141,6 +143,7 @@ export function BoardBody({
key={detailItem.id}
item={detailItem}
type={detailTarget.type}
+ chatLink={detailChatLink}
styles={styles}
accentColor={surfaceProps.theme.colors.accent}
foregroundColor={surfaceProps.theme.colors.foreground}
@@ -149,6 +152,7 @@ export function BoardBody({
onWidthCommitted={commitWidth}
progress={detailProgress}
onClose={closeDetails}
+ onOpenChat={(chat) => surfaceProps.navigation?.openAgent({ agentId: chat.agentId })}
onSend={openSendDialog}
onMerged={dropItem}
/>
diff --git a/client/board/board-row.styles.ts b/client/board/board-row.styles.ts
index 74dfcc7..9fcbf37 100644
--- a/client/board/board-row.styles.ts
+++ b/client/board/board-row.styles.ts
@@ -68,6 +68,7 @@ export function buildBoardRowStyles(
gap: 6,
marginTop: 6,
},
+ itemRowActions: { flexDirection: "row" as const, alignItems: "center" as const, gap: 4 },
/** Hidden until the row or the action itself is hovered; see `sendButtonHidden`. */
itemRowActionHidden: { opacity: 0 },
label: {
diff --git a/client/board/github-board.tsx b/client/board/github-board.tsx
index 3ca2152..f86ee11 100644
--- a/client/board/github-board.tsx
+++ b/client/board/github-board.tsx
@@ -51,6 +51,7 @@ export function GitHubBoard(props: PluginSurfaceProps) {
setBodyWidth,
detailTarget,
detailItem,
+ detailChatLink,
detailProgress,
closeDetails,
savedFraction,
@@ -154,6 +155,7 @@ export function GitHubBoard(props: PluginSurfaceProps) {
setBodyWidth={setBodyWidth}
detailTarget={detailTarget}
detailItem={detailItem}
+ detailChatLink={detailChatLink}
detailProgress={detailProgress}
closeDetails={closeDetails}
savedFraction={savedFraction}
diff --git a/client/board/item-row-format.ts b/client/board/item-row-format.ts
index fbaa594..9d40198 100644
--- a/client/board/item-row-format.ts
+++ b/client/board/item-row-format.ts
@@ -38,7 +38,7 @@ export interface RowDisplay {
*/
export function describeRow({
item,
- viewerLogin,
+ viewerLogins,
order,
type,
accentColor,
@@ -48,7 +48,7 @@ export function describeRow({
isWeb,
}: {
item: BoardItem;
- viewerLogin: string;
+ viewerLogins: readonly string[];
order: SortOrder;
type: ColumnId;
accentColor: string;
@@ -73,7 +73,8 @@ export function describeRow({
: "CircleDot";
const iconColor = type === "draft-prs" || type === "discussions" ? mutedColor : accentColor;
- const byline = item.author !== null && item.author !== viewerLogin ? item.author : null;
+ const byline =
+ item.author !== null && !viewerLogins.includes(item.author) ? item.author : null;
const openedBy = byline === null ? "" : `, opened by ${byline}`;
const linkedTo = closes === "" ? "" : `, closes ${closes}`;
diff --git a/client/board/item-row.tsx b/client/board/item-row.tsx
index b365fa2..85b27aa 100644
--- a/client/board/item-row.tsx
+++ b/client/board/item-row.tsx
@@ -8,6 +8,7 @@ import { linkedIssueLabel } from "../lib/formatting";
import { relativeTime } from "../lib/time";
import type { SortOrder } from "../lib/sort";
import type { Styles } from "../theme/use-styles";
+import type { ChatLink } from "./use-chat-links";
import { describeRow } from "./item-row-format";
import { ItemRowTrailing } from "./item-row-trailing";
@@ -52,14 +53,16 @@ function pointerPoint(event: unknown): { x: number; y: number } | null {
*/
export const ItemRow = memo(function ItemRow({
item,
- viewerLogin,
+ viewerLogins,
styles,
platform,
compact,
selected,
accentColor,
mutedColor,
+ chatLink,
onOpen,
+ onOpenChat,
onSend,
onLabels,
type,
@@ -67,7 +70,7 @@ export const ItemRow = memo(function ItemRow({
}: {
item: BoardItem;
/** The login the board was queried for, so a row of someone else's reads as one. */
- viewerLogin: string;
+ viewerLogins: readonly string[];
styles: Styles;
/**
* Decides two things the row cannot ask about itself: whether hovering
@@ -83,8 +86,11 @@ export const ItemRow = memo(function ItemRow({
accentColor: string;
/** The state glyph's colour for a draft or a discussion, and the send icon's. */
mutedColor: string;
+ /** The first Paseo chat associated with this pull request's workspace. */
+ chatLink: ChatLink | null;
/** A press: opens the row in the detail panel, never the browser. */
onOpen: (item: BoardItem, type: ColumnId) => void;
+ onOpenChat: (chat: ChatLink) => void;
onSend: (item: BoardItem, type: ColumnId) => void;
/** Null where labels cannot be edited, which takes the gesture away entirely. */
onLabels: ((item: BoardItem, point: { x: number; y: number }) => void) | null;
@@ -120,6 +126,10 @@ export const ItemRow = memo(function ItemRow({
onSend(item, type);
}, [item, onSend, type]);
+ const openChat = useCallback(() => {
+ if (chatLink !== null) onOpenChat(chatLink);
+ }, [chatLink, onOpenChat]);
+
const openLabels = useCallback(
(event: unknown) => {
const point = pointerPoint(event);
@@ -148,10 +158,11 @@ export const ItemRow = memo(function ItemRow({
const closes = item.linkedIssues
.map((issue) => linkedIssueLabel(issue, item.repository))
.join(", ");
+ const hostname = new URL(item.url).hostname;
const display = describeRow({
item,
- viewerLogin,
+ viewerLogins,
order,
type,
accentColor,
@@ -194,26 +205,45 @@ export const ItemRow = memo(function ItemRow({
{type === "draft-prs" ? Draft : null}
- {item.repository} #{item.number} · {display.stampLabel} {relativeTime(display.stampDate)}
+ {item.repository} #{item.number} · {hostname} · {display.stampLabel}{" "}
+ {relativeTime(display.stampDate)}
{display.byline !== null ? ` by ${display.byline}` : ""}
{compact ? {trailing} : null}
{compact ? null : {trailing}}
- setActionHovered(true)}
- onHoverOut={() => setActionHovered(false)}
- style={({ pressed }) => [
- styles.iconButton,
- revealed ? null : styles.itemRowActionHidden,
- pressed ? styles.cardPressed : null,
- ]}
- >
-
-
+
+ {chatLink === null ? null : (
+ setActionHovered(true)}
+ onHoverOut={() => setActionHovered(false)}
+ style={({ pressed }) => [
+ styles.iconButton,
+ revealed ? null : styles.itemRowActionHidden,
+ pressed ? styles.cardPressed : null,
+ ]}
+ >
+
+
+ )}
+ setActionHovered(true)}
+ onHoverOut={() => setActionHovered(false)}
+ style={({ pressed }) => [
+ styles.iconButton,
+ revealed ? null : styles.itemRowActionHidden,
+ pressed ? styles.cardPressed : null,
+ ]}
+ >
+
+
+
);
});
diff --git a/client/board/label-menu.tsx b/client/board/label-menu.tsx
index 2841ac9..4f77ade 100644
--- a/client/board/label-menu.tsx
+++ b/client/board/label-menu.tsx
@@ -56,6 +56,7 @@ export function LabelMenu({
onChanged: (itemId: string, labels: string[]) => void;
}) {
const { item } = target;
+ const host = new URL(item.url).hostname;
const list = useRpc(listLabels);
const apply = useRpc(toggleLabel);
@@ -68,8 +69,8 @@ export function LabelMenu({
* did: the surface unmounts on every workspace switch, the cache does not.
*/
const labelsQuery = useQuery({
- queryKey: ["repository-labels", item.repository],
- queryFn: () => list({ repository: item.repository }).then((result) => result.labels),
+ queryKey: ["repository-labels", host, item.repository],
+ queryFn: () => list({ repository: item.repository, host }).then((result) => result.labels),
staleTime: STALE_AFTER_MS,
});
const labels = labelsQuery.data ?? null;
diff --git a/client/board/use-board-overlays.tsx b/client/board/use-board-overlays.tsx
index b7e16dc..cf1c510 100644
--- a/client/board/use-board-overlays.tsx
+++ b/client/board/use-board-overlays.tsx
@@ -10,6 +10,7 @@ import type { BoardRow, SortOrder } from "../lib/sort";
import { DETAIL_CLOSE_MS, DETAIL_OPEN_MS } from "../detail/constants";
import { ItemRow } from "./item-row";
import type { Styles } from "../theme/use-styles";
+import type { ChatLink } from "./use-chat-links";
import { LABEL_MENU_MAX_HEIGHT, LABEL_MENU_WIDTH, MENU_MARGIN } from "./label-menu";
import type { LabelMenuTarget } from "./label-menu";
@@ -23,6 +24,7 @@ export interface UseBoardOverlaysInputs {
mutateBoardCache: (updater: (current: Board) => Board) => void;
/** The active sort's row label and date accessor, for `ItemRow`'s meta line. */
activeOrder: SortOrder;
+ chatLinkForItem: (item: BoardItem) => ChatLink | null;
}
/** What `useBoardOverlays` exposes to the surface. */
@@ -31,6 +33,7 @@ export interface UseBoardOverlaysResult {
setBodyWidth: (next: number | null) => void;
detailTarget: { item: BoardItem; type: ColumnId } | null;
detailItem: BoardItem | null;
+ detailChatLink: ChatLink | null;
detailProgress: Animated.Value;
closeDetails: () => void;
openSendDialog: (item: BoardItem, type: ColumnId) => void;
@@ -58,7 +61,7 @@ export interface UseBoardOverlaysResult {
export function useBoardOverlays(
props: PluginSurfaceProps,
styles: Styles,
- { board, promptValues, mutateBoardCache, activeOrder }: UseBoardOverlaysInputs,
+ { board, promptValues, mutateBoardCache, activeOrder, chatLinkForItem }: UseBoardOverlaysInputs,
): UseBoardOverlaysResult {
const toast = useToast();
@@ -200,6 +203,14 @@ export function useBoardOverlays(
}, [board, detailTarget]);
const selectedId = detailItem?.id ?? null;
+ const detailChatLink = detailItem === null ? null : chatLinkForItem(detailItem);
+
+ const openChat = useCallback(
+ (chat: ChatLink) => {
+ props.navigation?.openAgent({ agentId: chat.agentId });
+ },
+ [props.navigation],
+ );
/**
* Opens the launch dialog on this card, with the card's template already
@@ -249,14 +260,16 @@ export function useBoardOverlays(
({ item: row }: { item: BoardRow }) => (
),
[
- board?.login,
+ board?.viewerLogins,
styles,
props.layout.platform,
props.layout.compact,
selectedId,
props.theme.colors.accent,
props.theme.colors.foregroundMuted,
+ chatLinkForItem,
openDetails,
+ openChat,
openSendDialog,
openLabelMenu,
activeOrder,
@@ -283,6 +298,7 @@ export function useBoardOverlays(
setBodyWidth,
detailTarget,
detailItem,
+ detailChatLink,
detailProgress,
closeDetails,
openSendDialog,
diff --git a/client/board/use-chat-links.test.ts b/client/board/use-chat-links.test.ts
new file mode 100644
index 0000000..28456ba
--- /dev/null
+++ b/client/board/use-chat-links.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+import { buildChatLinks, chatLinkFor } from "./use-chat-links";
+
+describe("buildChatLinks", () => {
+ it("links a pull request to the first chat in its workspace", () => {
+ const links = buildChatLinks(
+ [
+ {
+ id: "workspace-1",
+ name: "Fix dashboard",
+ githubRuntime: {
+ pullRequest: { url: "https://ghe.example.com/an-org/example/pull/42" },
+ },
+ },
+ ],
+ [
+ {
+ agent: {
+ id: "follow-up",
+ title: "Review",
+ workspaceId: "workspace-1",
+ createdAt: "2026-09-16T12:00:00Z",
+ },
+ },
+ {
+ agent: {
+ id: "original",
+ title: "Implement",
+ workspaceId: "workspace-1",
+ createdAt: "2026-09-15T12:00:00Z",
+ },
+ },
+ ],
+ );
+
+ expect(chatLinkFor(links, "https://ghe.example.com/an-org/example/pull/42/")).toEqual({
+ agentId: "original",
+ agentTitle: "Implement",
+ workspaceId: "workspace-1",
+ workspaceName: "Fix dashboard",
+ });
+ });
+
+ it("does not link a workspace with no chat", () => {
+ const links = buildChatLinks(
+ [
+ {
+ id: "workspace-1",
+ name: "No agent",
+ githubRuntime: { pullRequest: { url: "https://github.com/an-owner/example/pull/1" } },
+ },
+ ],
+ [],
+ );
+
+ expect(links.size).toBe(0);
+ });
+});
diff --git a/client/board/use-chat-links.ts b/client/board/use-chat-links.ts
new file mode 100644
index 0000000..da767bf
--- /dev/null
+++ b/client/board/use-chat-links.ts
@@ -0,0 +1,152 @@
+import { usePaseo } from "@getpaseo/plugin/client";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useMemo } from "react";
+
+const PAGE_LIMIT = 200;
+const MAX_PAGES = 10;
+const BACKSTOP_REFRESH_MS = 60_000;
+const EVENT_DEBOUNCE_MS = 500;
+
+type PaseoApi = ReturnType;
+type Workspace = Awaited>["entries"][number];
+type AgentEntry = Awaited>["entries"][number];
+
+export interface ChatLink {
+ agentId: string;
+ agentTitle: string;
+ workspaceId: string;
+ workspaceName: string;
+}
+
+export interface WorkspaceChatSource {
+ id: string;
+ name: string;
+ githubRuntime?: { pullRequest?: { url: string } | null | undefined } | null | undefined;
+}
+
+export interface AgentChatSource {
+ agent: {
+ id: string;
+ title?: string | null | undefined;
+ workspaceId?: string | undefined;
+ createdAt: string;
+ };
+}
+
+function normalizePullRequestUrl(url: string): string {
+ return url.trim().replace(/\/+$/, "").toLowerCase();
+}
+
+async function loadWorkspaces(paseo: PaseoApi): Promise {
+ const entries: Workspace[] = [];
+ let cursor: string | undefined;
+ for (let page = 0; page < MAX_PAGES; page += 1) {
+ const result = await paseo.workspaces.list({
+ page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
+ });
+ entries.push(...result.entries);
+ cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined;
+ if (!cursor) break;
+ }
+ return entries;
+}
+
+async function loadAgents(paseo: PaseoApi): Promise {
+ const entries: AgentEntry[] = [];
+ let cursor: string | undefined;
+ for (let page = 0; page < MAX_PAGES; page += 1) {
+ const result = await paseo.agents.list({
+ filter: { includeArchived: true },
+ sort: [{ key: "created_at", direction: "asc" }],
+ page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
+ });
+ entries.push(...result.entries);
+ cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined;
+ if (!cursor) break;
+ }
+ return entries;
+}
+
+/**
+ * Match each PR tracked by Paseo's workspace runtime to the first chat created
+ * in that workspace. The first agent is the originating conversation; later
+ * agents may be reviewers or follow-up repair work.
+ */
+export function buildChatLinks(
+ workspaces: readonly WorkspaceChatSource[],
+ agents: readonly AgentChatSource[],
+): Map {
+ const firstAgentByWorkspace = new Map();
+ for (const { agent } of agents) {
+ if (!agent.workspaceId) continue;
+ const current = firstAgentByWorkspace.get(agent.workspaceId);
+ if (current === undefined || agent.createdAt.localeCompare(current.createdAt) < 0) {
+ firstAgentByWorkspace.set(agent.workspaceId, agent);
+ }
+ }
+
+ const links = new Map();
+ for (const workspace of workspaces) {
+ const url = workspace.githubRuntime?.pullRequest?.url;
+ if (!url) continue;
+ const agent = firstAgentByWorkspace.get(workspace.id);
+ if (agent === undefined) continue;
+
+ const key = normalizePullRequestUrl(url);
+ const existing = links.get(key);
+ const candidate: ChatLink = {
+ agentId: agent.id,
+ agentTitle: agent.title?.trim() || agent.id.slice(0, 7),
+ workspaceId: workspace.id,
+ workspaceName: workspace.name,
+ };
+ if (existing === undefined) {
+ links.set(key, candidate);
+ continue;
+ }
+
+ const existingAgent = agents.find(({ agent: entry }) => entry.id === existing.agentId)?.agent;
+ if (existingAgent !== undefined && agent.createdAt.localeCompare(existingAgent.createdAt) < 0) {
+ links.set(key, candidate);
+ }
+ }
+ return links;
+}
+
+export function useChatLinks(hostId: string): Map {
+ const paseo = usePaseo();
+ const queryClient = useQueryClient();
+ const queryKey = useMemo(() => ["github-board-chat-links", hostId], [hostId]);
+ const { data } = useQuery({
+ queryKey,
+ queryFn: async () => {
+ const [workspaces, agents] = await Promise.all([loadWorkspaces(paseo), loadAgents(paseo)]);
+ return buildChatLinks(workspaces, agents);
+ },
+ refetchInterval: BACKSTOP_REFRESH_MS,
+ });
+
+ useEffect(() => {
+ let timer: ReturnType | undefined;
+ const invalidate = () => {
+ if (timer !== undefined) return;
+ timer = setTimeout(() => {
+ timer = undefined;
+ void queryClient.invalidateQueries({ queryKey });
+ }, EVENT_DEBOUNCE_MS);
+ };
+ const unsubscribeAgents = paseo.agents.subscribe(invalidate);
+ const unsubscribeWorkspaces = paseo.workspaces.subscribe(invalidate);
+ return () => {
+ if (timer !== undefined) clearTimeout(timer);
+ unsubscribeAgents();
+ unsubscribeWorkspaces();
+ };
+ }, [paseo, queryClient, queryKey]);
+
+ return data ?? new Map();
+}
+
+export function chatLinkFor(links: ReadonlyMap, url: string): ChatLink | null {
+ return links.get(normalizePullRequestUrl(url)) ?? null;
+}
diff --git a/client/board/use-github-board.tsx b/client/board/use-github-board.tsx
index 5d0b20e..68d3ca7 100644
--- a/client/board/use-github-board.tsx
+++ b/client/board/use-github-board.tsx
@@ -5,6 +5,7 @@ import { useBoardFilters } from "./use-board-filters";
import { useBoardOverlays } from "./use-board-overlays";
import { useBoardQuery } from "./use-board-query";
import { useBoardSettings } from "./use-board-settings";
+import { chatLinkFor, useChatLinks } from "./use-chat-links";
/**
* Everything the board surface needs beyond its own JSX: the fetch and its
@@ -22,11 +23,13 @@ export function useGitHubBoard(props: PluginSurfaceProps, styles: Styles) {
const settings = useBoardSettings();
const query = useBoardQuery(props, settings.watchedOwners);
const filters = useBoardFilters(query.board, settings.display);
+ const chatLinks = useChatLinks(props.host.id);
const overlays = useBoardOverlays(props, styles, {
board: query.board,
promptValues: settings.promptValues,
mutateBoardCache: query.mutateBoardCache,
activeOrder: filters.activeOrder,
+ chatLinkForItem: (item) => chatLinkFor(chatLinks, item.url),
});
return {
@@ -69,6 +72,7 @@ export function useGitHubBoard(props: PluginSurfaceProps, styles: Styles) {
setBodyWidth: overlays.setBodyWidth,
detailTarget: overlays.detailTarget,
detailItem: overlays.detailItem,
+ detailChatLink: overlays.detailChatLink,
detailProgress: overlays.detailProgress,
closeDetails: overlays.closeDetails,
savedFraction: settings.savedFraction,
diff --git a/client/detail/detail-actions-row.tsx b/client/detail/detail-actions-row.tsx
index c60de61..e1de091 100644
--- a/client/detail/detail-actions-row.tsx
+++ b/client/detail/detail-actions-row.tsx
@@ -1,6 +1,7 @@
import { Pressable, Text, View } from "react-native";
import type { BoardItem, ColumnId, ItemDetails } from "../../shared/board";
+import type { ChatLink } from "../board/use-chat-links";
import type { Styles } from "../theme/use-styles";
import { openExternalUrl } from "../web";
import { ReviewActions } from "./review-actions";
@@ -15,20 +16,24 @@ import { ReviewActions } from "./review-actions";
export function DetailActionsRow({
item,
type,
+ chatLink,
details,
styles,
acting,
onApprove,
onMergeOpen,
+ onOpenChat,
onSend,
}: {
item: BoardItem;
type: ColumnId;
+ chatLink: ChatLink | null;
details: ItemDetails | null;
styles: Styles;
acting: boolean;
onApprove: () => void;
onMergeOpen: () => void;
+ onOpenChat: (chat: ChatLink) => void;
onSend: (item: BoardItem, type: ColumnId) => void;
}) {
return (
@@ -43,6 +48,16 @@ export function DetailActionsRow({
onMerge={onMergeOpen}
/>
) : null}
+ {chatLink === null ? null : (
+ [styles.button, pressed ? styles.sendButtonPressed : null]}
+ onPress={() => onOpenChat(chatLink)}
+ >
+ Open original chat
+
+ )}
void;
onClose: () => void;
+ onOpenChat: (chat: ChatLink) => void;
onSend: (item: BoardItem, type: ColumnId) => void;
onMerged: (itemId: string) => void;
}) {
@@ -129,11 +134,13 @@ export function ItemDetailPanel({
actions.setMergeOpen(true)}
+ onOpenChat={onOpenChat}
onSend={onSend}
/>
diff --git a/client/projects/project-board-view.tsx b/client/projects/project-board-view.tsx
index a31a7d7..f442da3 100644
--- a/client/projects/project-board-view.tsx
+++ b/client/projects/project-board-view.tsx
@@ -45,6 +45,7 @@ function itemGlyph(item: ProjectItem, colors: ThemeColors): { name: string; colo
export function ProjectBoardView({
theme,
styles,
+ host,
owner,
number,
onOpenUrl,
@@ -52,6 +53,7 @@ export function ProjectBoardView({
}: {
theme: PluginSurfaceProps["theme"];
styles: ProjectsStyles;
+ host: string;
owner: string;
number: number;
onOpenUrl: (url: string) => void;
@@ -68,7 +70,7 @@ export function ProjectBoardView({
setDetail(null);
setDetailError(null);
setDetailLoading(true);
- fetchProject({ owner, number, force: false })
+ fetchProject({ host, owner, number, force: false })
.then((result) => {
if (live) setDetail({ title: result.title, url: result.url, columns: result.columns });
})
@@ -81,7 +83,7 @@ export function ProjectBoardView({
return () => {
live = false;
};
- }, [fetchProject, owner, number]);
+ }, [fetchProject, host, owner, number]);
/**
* An empty column is not the interesting case, but a project with nothing
diff --git a/client/projects/projects-list-view.tsx b/client/projects/projects-list-view.tsx
index cfc0e68..8267e84 100644
--- a/client/projects/projects-list-view.tsx
+++ b/client/projects/projects-list-view.tsx
@@ -26,15 +26,13 @@ const SCOPE_COMMAND = "gh auth refresh -h github.com -s read:project";
export function ProjectsListView({
theme,
styles,
- login,
owners,
onOpenProject,
}: {
theme: PluginSurfaceProps["theme"];
styles: ProjectsStyles;
- login: string;
owners: readonly string[];
- onOpenProject: (owner: string, number: number) => void;
+ onOpenProject: (host: string, owner: string, number: number) => void;
}) {
const fetchProjects = useRpc(listProjects);
@@ -56,9 +54,7 @@ export function ProjectsListView({
setListLoading(true);
setListError(null);
try {
- const input =
- login === "" ? { owners: [...owners], force } : { login, owners: [...owners], force };
- const result = await fetchProjects(input);
+ const result = await fetchProjects({ owners: [...owners], force });
setProjects(result.projects);
setListError(result.error);
setNeedsScope(result.needsScope);
@@ -70,7 +66,7 @@ export function ProjectsListView({
setListLoading(false);
}
},
- [fetchProjects, login, ownersKey],
+ [fetchProjects, ownersKey],
);
useEffect(() => {
@@ -146,7 +142,7 @@ export function ProjectsListView({
key={project.id}
style={styles.projectRow}
accessibilityRole="button"
- onPress={() => onOpenProject(project.owner, project.number)}
+ onPress={() => onOpenProject(project.host, project.owner, project.number)}
>
@@ -159,7 +155,9 @@ export function ProjectsListView({
) : null}
- {project.owner}
+
+ {project.owner} · {project.host}
+
·
{project.itemCount} {project.itemCount === 1 ? "item" : "items"}
diff --git a/client/projects/projects-view.tsx b/client/projects/projects-view.tsx
index 331ab57..976c73e 100644
--- a/client/projects/projects-view.tsx
+++ b/client/projects/projects-view.tsx
@@ -15,6 +15,7 @@ import { buildProjectsStyles } from "./projects.styles";
/** Which project's own board is open, or `null` for the list. */
interface ProjectTarget {
+ host: string;
owner: string;
number: number;
}
@@ -30,11 +31,10 @@ interface ProjectTarget {
export function ProjectsView(props: {
theme: PluginSurfaceProps["theme"];
layout: PluginSurfaceProps["layout"];
- login: string;
owners: readonly string[];
onOpenUrl: (url: string) => void;
}): JSX.Element {
- const { theme, layout, login, owners, onOpenUrl } = props;
+ const { theme, layout, owners, onOpenUrl } = props;
const styles = useMemo(() => buildProjectsStyles(theme, layout), [theme, layout]);
const [target, setTarget] = useState(null);
@@ -43,6 +43,7 @@ export function ProjectsView(props: {
setTarget({ owner, number })}
+ onOpenProject={(host, owner, number) => setTarget({ host, owner, number })}
/>
);
}
diff --git a/package-lock.json b/package-lock.json
index 722cb84..c2162bb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "paseo-github-integration",
- "version": "0.5.0",
+ "version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo-github-integration",
- "version": "0.5.0",
+ "version": "1.0.1",
"dependencies": {
"@getpaseo/plugin": "0.8.0-beta.1"
},
diff --git a/server/board/buckets.test.ts b/server/board/buckets.test.ts
index 61ed2c0..c7c0798 100644
--- a/server/board/buckets.test.ts
+++ b/server/board/buckets.test.ts
@@ -68,7 +68,7 @@ describe("mergeBucketResults", () => {
expect(merged?.relations).toEqual(["author", "owned"]);
});
- it("applies the limit to the merged, sorted union rather than per bucket", () => {
+ it("applies the limit per relation and returns the union newest first", () => {
const buckets: BucketResult[] = [
{
relation: "author",
@@ -86,9 +86,24 @@ describe("mergeBucketResults", () => {
},
];
const result = mergeBucketResults(buckets, toBoardItem, 2);
- expect(result).toHaveLength(2);
- // The two newest across both buckets, not the first two encountered.
- expect(result.map((item) => item.id)).toEqual(["new-1", "new-2"]);
+ expect(result.map((item) => item.id)).toEqual(["new-1", "new-2", "old-2", "old-1"]);
+ });
+
+ it("keeps an older authored item when a newer relation fills the budget", () => {
+ // The review queue this models is what a shared budget spent entirely:
+ // every review request is newer than the one pull request the viewer wrote.
+ const buckets: BucketResult[] = [
+ {
+ relation: "review-requested",
+ nodes: [
+ { id: "review-1", fakeUpdatedAt: "2024-06-03T00:00:00Z" },
+ { id: "review-2", fakeUpdatedAt: "2024-06-02T00:00:00Z" },
+ ],
+ },
+ { relation: "author", nodes: [{ id: "mine", fakeUpdatedAt: "2024-01-01T00:00:00Z" }] },
+ ];
+ const result = mergeBucketResults(buckets, toBoardItem, 2);
+ expect(result.map((item) => item.id)).toContain("mine");
});
it("drops a node that toBoardItem rejects", () => {
diff --git a/server/board/buckets.ts b/server/board/buckets.ts
index 6fce4d2..b88bc71 100644
--- a/server/board/buckets.ts
+++ b/server/board/buckets.ts
@@ -82,6 +82,12 @@ export async function runBuckets(
* null for a node the caller wants dropped entirely (an archived discussion,
* an empty node from the other inline fragment matching nothing), which is
* why it runs before the relation is ever recorded.
+ *
+ * `limit` is a budget per relation, not one shared by the union. A shared
+ * budget is spent by whichever relation happens to have the most recently
+ * updated items: a full review queue is newer than almost anything else, so
+ * it took the whole list and left the viewer's own pull requests off a board
+ * that exists to show them.
*/
export function mergeBucketResults(
buckets: readonly BucketResult[],
@@ -110,5 +116,13 @@ export function mergeBucketResults(
for (const item of byId.values()) {
item.relations.sort((a, b) => RELATION_ORDER[a] - RELATION_ORDER[b]);
}
- return [...byId.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit);
+
+ const items = [...byId.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
+ const kept = new Set();
+ for (const relation of new Set(buckets.map((bucket) => bucket.relation))) {
+ for (const item of items.filter((item) => item.relations.includes(relation)).slice(0, limit)) {
+ kept.add(item.id);
+ }
+ }
+ return items.filter((item) => kept.has(item.id));
}
diff --git a/server/board/checks.ts b/server/board/checks.ts
index 47e2de0..1c3138b 100644
--- a/server/board/checks.ts
+++ b/server/board/checks.ts
@@ -1,5 +1,6 @@
import type { BoardItem, CheckSummary } from "../../shared/board";
import { gh } from "../github/gh";
+import { decodeItemId } from "../github/item-id";
/**
* The checks on each pull request's head commit, by node id.
@@ -11,9 +12,8 @@ import { gh } from "../github/gh";
* would turn that into a blank Draft PRs *and* Open PRs column; asking for it
* separately costs pills nobody could have seen anyway.
*
- * `nodes(ids:)` takes at most 100 ids, which the caller cannot exceed: it asks
- * only for the open pull requests, and the merged list was already cut to
- * `limit`, whose own ceiling is 100.
+ * `nodes(ids:)` takes at most 100 ids, so `attachChecks` asks in batches of
+ * that size rather than assuming the open pull requests fit in one.
*/
const CHECKS_QUERY = `query($ids: [ID!]!) {
nodes(ids: $ids) {
@@ -57,6 +57,9 @@ const CHECKS_QUERY = `query($ids: [ID!]!) {
*/
type CheckOutcome = "passed" | "failed" | "pending" | "ignored";
+/** GitHub's own ceiling on `nodes(ids:)`. */
+const CHECKS_BATCH = 100;
+
/**
* Mirrors Paseo's `mapCheckRunStatus` so the board and the sidebar cannot
* disagree about the same pull request, with one deliberate difference:
@@ -211,16 +214,25 @@ async function fetchChecks(ids: readonly string[]): Promise