feat(site): show PR link in TopBar header (#23178)

When a PR is detected for a chat, display a compact PR badge in the
AgentDetail TopBar. On mobile it is always visible; on desktop it is
hidden when the sidebar panel is open (which already surfaces PR info)
and shown when the panel is closed.

The badge shows a state-colored icon (open, draft, merged, closed) and
the PR title or number, linking to the PR URL. Only URLs confirmed as
real PRs (via explicit `pull_request_state` or a `/pull/<number>`
pathname) trigger the badge.

## Changes

- **`TopBar.tsx`** — Added `diffStatusData` prop, `PrStateIcon` helper,
and a PR link badge between the title and actions area. Hidden on
desktop when the sidebar panel is open.
- **`AgentDetailView.tsx`** — Pass `diffStatusData` through to
`AgentDetailTopBar`.
- **`TopBar.stories.tsx`** — Added stories for open, draft, merged, and
closed PR states.
This commit is contained in:
Michael Suchacz
2026-03-18 13:40:33 +01:00
committed by GitHub
parent 0d0c6c956d
commit 62144d230f
7 changed files with 250 additions and 23 deletions
+8 -3
View File
@@ -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.
@@ -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,
@@ -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 <GitMergeIcon className={cn("text-git-merged-bright", className)} />;
}
if (state === "closed") {
return (
<GitPullRequestClosedIcon
className={cn("text-git-deleted-bright", className)}
/>
);
}
if (draft) {
return (
<GitPullRequestDraftIcon
className={cn("text-content-secondary", className)}
/>
);
}
return (
<GitPullRequestArrowIcon
className={cn("text-git-added-bright", className)}
/>
);
};
export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
@@ -69,10 +106,20 @@ export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
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 (
<div className="flex shrink-0 items-center gap-2 px-4 py-1.5">
{/* Mobile back button */}
@@ -122,6 +169,29 @@ export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
</div>
)}
</div>
{/* PR link — visible on mobile always, hidden on desktop
when the sidebar panel is open (which already shows PR
info). */}
{prUrl && hasPR && (
<a
href={prUrl}
target="_blank"
rel="noreferrer"
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-md border border-solid border-border-default px-2 py-0.5 text-xs font-medium text-content-secondary no-underline transition-colors hover:bg-surface-secondary hover:text-content-primary",
panel.showSidebarPanel && "md:hidden",
)}
>
<PrStateIcon
state={prState}
draft={prDraft}
className="!size-3.5 shrink-0"
/>
<span className="truncate max-w-[120px]">
{prTitle || (prNumberMatch ? `#${prNumberMatch}` : "PR")}
</span>
</a>
)}
{/* Actions area */}
<div className="flex items-center gap-2">
{!isEmbedded && (
@@ -238,6 +238,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
onArchiveAndDeleteWorkspace={handleArchiveAndDeleteWorkspaceAction}
hasWorkspace={hasWorkspace}
isArchived={isArchived}
diffStatusData={diffStatusData}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
+1 -20
View File
@@ -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
// -------------------------------------------------------------------
@@ -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();
});
});
+48
View File
@@ -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;
}
};