mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user