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} />