diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index 0c01d375dd..41d02eb464 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -59,6 +59,7 @@ import { getModelSelectorPlaceholder, hasConfiguredModelsInCatalog, } from "./modelOptions"; +import { parsePullRequestUrl } from "./pullRequest"; import { formatUsageLimitMessage, isUsageLimitData } from "./usageLimitMessage"; import { useGitWatcher } from "./useGitWatcher"; @@ -470,9 +471,13 @@ const AgentDetail: FC = () => { chatInputRef.current?.focus(); }, []); - // Extract PR number from diff status URL. - const prMatch = chatQuery.data?.diff_status?.url?.match(/\/pull\/(\d+)/)?.[1]; - const prNumber = prMatch ? Number(prMatch) : undefined; + // Prefer the explicit PR number from the API, and only fall back to URL + // parsing when older metadata does not provide it. + const parsedPrNumber = Number( + parsePullRequestUrl(chatQuery.data?.diff_status?.url)?.number, + ); + const prNumber = + chatQuery.data?.diff_status?.pr_number ?? (parsedPrNumber || undefined); // Compute an effective selected model by validating the user's // explicit choice against the current model options, falling // back to the chat's last model or the first available option. diff --git a/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx b/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx index bad14392c2..e794833fa5 100644 --- a/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx @@ -80,6 +80,68 @@ export const NoTitle: Story = { }, }; +export const WithOpenPR: Story = { + args: { + diffStatusData: { + chat_id: "chat-1", + url: "https://github.com/coder/coder/pull/123", + pull_request_title: "fix: resolve race condition in workspace builds", + pull_request_draft: false, + changes_requested: false, + additions: 42, + deletions: 7, + changed_files: 5, + }, + }, +}; + +export const WithDraftPR: Story = { + args: { + diffStatusData: { + chat_id: "chat-1", + url: "https://github.com/coder/coder/pull/456", + pull_request_title: "feat: add new notification system", + pull_request_draft: true, + changes_requested: false, + additions: 120, + deletions: 30, + changed_files: 8, + }, + }, +}; + +export const WithMergedPR: Story = { + args: { + diffStatusData: { + chat_id: "chat-1", + url: "https://github.com/coder/coder/pull/789", + pull_request_title: "chore: update dependencies", + pull_request_state: "merged", + pull_request_draft: false, + changes_requested: false, + additions: 5, + deletions: 3, + changed_files: 1, + }, + }, +}; + +export const WithClosedPR: Story = { + args: { + diffStatusData: { + chat_id: "chat-1", + url: "https://github.com/coder/coder/pull/101", + pull_request_title: "fix: deprecated API cleanup", + pull_request_state: "closed", + pull_request_draft: false, + changes_requested: false, + additions: 0, + deletions: 50, + changed_files: 3, + }, + }, +}; + export const ArchivedWithUnarchive: Story = { args: { isArchived: true, diff --git a/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx b/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx index 2c897c1ed6..d833c54e62 100644 --- a/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx @@ -1,4 +1,5 @@ import type * as TypesGen from "api/typesGenerated"; +import type { ChatDiffStatus } from "api/typesGenerated"; import { Button } from "components/Button/Button"; import { DropdownMenu, @@ -15,6 +16,10 @@ import { CopyIcon, EllipsisIcon, ExternalLinkIcon, + GitMergeIcon, + GitPullRequestArrowIcon, + GitPullRequestClosedIcon, + GitPullRequestDraftIcon, MonitorIcon, PanelLeftIcon, PanelRightCloseIcon, @@ -25,7 +30,9 @@ import { import type { FC } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; +import { cn } from "utils/cn"; import { useEmbedContext } from "../EmbedContext"; +import { parsePullRequestUrl } from "../pullRequest"; interface SidebarPanelState { showSidebarPanel: boolean; @@ -54,6 +61,36 @@ type AgentDetailTopBarProps = { isArchived?: boolean; isSidebarCollapsed: boolean; onToggleSidebarCollapsed: () => void; + diffStatusData?: ChatDiffStatus; +}; + +const PrStateIcon: FC<{ + state?: string; + draft?: boolean; + className?: string; +}> = ({ state, draft, className }) => { + if (state === "merged") { + return ; + } + if (state === "closed") { + return ( + + ); + } + if (draft) { + return ( + + ); + } + return ( + + ); }; export const AgentDetailTopBar: FC = ({ @@ -69,10 +106,20 @@ export const AgentDetailTopBar: FC = ({ isArchived, isSidebarCollapsed, onToggleSidebarCollapsed, + diffStatusData, }) => { const navigate = useNavigate(); const { isEmbedded } = useEmbedContext(); + const prUrl = diffStatusData?.url; + const prState = diffStatusData?.pull_request_state; + const prDraft = diffStatusData?.pull_request_draft; + const prTitle = diffStatusData?.pull_request_title; + const parsedPr = parsePullRequestUrl(prUrl); + const prNumberMatch = + diffStatusData?.pr_number?.toString() ?? parsedPr?.number; + const hasPR = Boolean(prState || prNumberMatch || parsedPr); + return (
{/* Mobile back button */} @@ -122,6 +169,29 @@ export const AgentDetailTopBar: FC = ({
)} + {/* PR link — visible on mobile always, hidden on desktop + when the sidebar panel is open (which already shows PR + info). */} + {prUrl && hasPR && ( + + + + {prTitle || (prNumberMatch ? `#${prNumberMatch}` : "PR")} + + + )} {/* Actions area */}
{!isEmbedded && ( diff --git a/site/src/pages/AgentsPage/AgentDetailView.tsx b/site/src/pages/AgentsPage/AgentDetailView.tsx index a2bb9c365b..698f9cdcc0 100644 --- a/site/src/pages/AgentsPage/AgentDetailView.tsx +++ b/site/src/pages/AgentsPage/AgentDetailView.tsx @@ -238,6 +238,7 @@ export const AgentDetailView: FC = ({ onArchiveAndDeleteWorkspace={handleArchiveAndDeleteWorkspaceAction} hasWorkspace={hasWorkspace} isArchived={isArchived} + diffStatusData={diffStatusData} isSidebarCollapsed={isSidebarCollapsed} onToggleSidebarCollapsed={onToggleSidebarCollapsed} /> diff --git a/site/src/pages/AgentsPage/RemoteDiffPanel.tsx b/site/src/pages/AgentsPage/RemoteDiffPanel.tsx index 029b4cd9dd..d882bed02c 100644 --- a/site/src/pages/AgentsPage/RemoteDiffPanel.tsx +++ b/site/src/pages/AgentsPage/RemoteDiffPanel.tsx @@ -28,6 +28,7 @@ import type { ChatMessageInputRef } from "./AgentChatInput"; import { DiffStatBadge } from "./DiffStats"; import type { DiffStyle } from "./DiffViewer"; import { DiffViewer } from "./DiffViewer"; +import { parsePullRequestUrl } from "./pullRequest"; // ------------------------------------------------------------------- // Diff content extraction @@ -104,10 +105,6 @@ function extractDiffContent( return collected.join("\n"); } -/** - * Parses a GitHub PR URL into its components. - * Returns null if parsing fails. - */ // ------------------------------------------------------------------- // PR state badge // ------------------------------------------------------------------- @@ -147,22 +144,6 @@ const PullRequestStateBadge: FC<{ ); }; -function parsePullRequestUrl(url: string): { - owner: string; - repo: string; - number: string; -} | null { - try { - const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/); - if (match) { - return { owner: match[1], repo: match[2], number: match[3] }; - } - } catch { - // Fall through. - } - return null; -} - // ------------------------------------------------------------------- // Inline prompt input // ------------------------------------------------------------------- diff --git a/site/src/pages/AgentsPage/pullRequest.test.ts b/site/src/pages/AgentsPage/pullRequest.test.ts new file mode 100644 index 0000000000..b7e5810e32 --- /dev/null +++ b/site/src/pages/AgentsPage/pullRequest.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { parsePullRequestUrl } from "./pullRequest"; + +describe("parsePullRequestUrl", () => { + it("parses canonical GitHub pull request URLs", () => { + expect( + parsePullRequestUrl("https://github.com/coder/coder/pull/42"), + ).toEqual({ + owner: "coder", + repo: "coder", + number: "42", + }); + }); + + it("parses pull request URLs behind enterprise path prefixes", () => { + expect( + parsePullRequestUrl("https://git.example.com/git/org/repo/pull/42"), + ).toEqual({ + owner: "org", + repo: "repo", + number: "42", + }); + }); + + it("parses pull request URLs with suffix pages", () => { + expect( + parsePullRequestUrl("https://github.com/coder/coder/pull/42/files"), + ).toEqual({ + owner: "coder", + repo: "coder", + number: "42", + }); + }); + + it("parses enterprise pull request URLs with suffix pages", () => { + expect( + parsePullRequestUrl("https://git.example.com/git/org/repo/pull/42/files"), + ).toEqual({ + owner: "org", + repo: "repo", + number: "42", + }); + }); + + it("ignores branch URLs that only contain pull-like path segments", () => { + expect( + parsePullRequestUrl( + "https://github.com/coder/coder/tree/feature/pull/123/fix", + ), + ).toBeNull(); + }); + + it("ignores non-pull request repository pages", () => { + expect( + parsePullRequestUrl( + "https://git.example.com/git/org/repo/compare/main...feature", + ), + ).toBeNull(); + }); +}); diff --git a/site/src/pages/AgentsPage/pullRequest.ts b/site/src/pages/AgentsPage/pullRequest.ts new file mode 100644 index 0000000000..b8d2d4033b --- /dev/null +++ b/site/src/pages/AgentsPage/pullRequest.ts @@ -0,0 +1,48 @@ +const repoContentRoutePattern = + /\/(?:tree|blob|compare|commit|commits|branches|releases|tags|wiki)\//; + +export const parsePullRequestUrl = ( + url: string | null | undefined, +): { owner: string; repo: string; number: string } | null => { + if (!url) { + return null; + } + + try { + const { pathname } = new URL(url); + const segments = pathname.split("/").filter(Boolean); + if (segments.length < 4) { + return null; + } + + const pullSegmentIndex = segments.findIndex((segment, index) => { + if (segment !== "pull") { + return false; + } + + const number = segments.at(index + 1); + if (!number || !/^\d+$/.test(number)) { + return false; + } + + const leadingPath = `/${segments.slice(0, index).join("/")}/`; + return !repoContentRoutePattern.test(leadingPath); + }); + if (pullSegmentIndex < 2) { + return null; + } + + const number = segments.at(pullSegmentIndex + 1); + if (!number) { + return null; + } + + return { + owner: segments.at(pullSegmentIndex - 2) ?? "", + repo: segments.at(pullSegmentIndex - 1) ?? "", + number, + }; + } catch { + return null; + } +};