diff --git a/client/board/board-body.tsx b/client/board/board-body.tsx
index 5742403..162dbfc 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;
@@ -141,6 +144,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 +153,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.tsx b/client/board/item-row.tsx
index b365fa2..cecf6ec 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";
@@ -59,7 +60,9 @@ export const ItemRow = memo(function ItemRow({
selected,
accentColor,
mutedColor,
+ chatLink,
onOpen,
+ onOpenChat,
onSend,
onLabels,
type,
@@ -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);
@@ -200,20 +210,38 @@ export const ItemRow = memo(function ItemRow({
{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/use-board-overlays.tsx b/client/board/use-board-overlays.tsx
index b7e16dc..dbebce1 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
@@ -256,7 +267,9 @@ export function useBoardOverlays(
selected={row.item.id === selectedId}
accentColor={props.theme.colors.accent}
mutedColor={props.theme.colors.foregroundMuted}
+ chatLink={chatLinkForItem(row.item)}
onOpen={openDetails}
+ onOpenChat={openChat}
onSend={openSendDialog}
onLabels={row.type === "discussions" ? null : openLabelMenu}
type={row.type}
@@ -271,7 +284,9 @@ export function useBoardOverlays(
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}
/>